Skip to main content

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 Default for CSharpEngine {
17    fn default() -> Self {
18        Self::new()
19    }
20}
21
22impl CSharpEngine {
23    pub fn new() -> Self {
24        let runtime = resolve_dotnet_runtime();
25        let target_framework = runtime
26            .as_ref()
27            .and_then(|path| detect_target_framework(path).ok());
28        Self {
29            runtime,
30            target_framework,
31        }
32    }
33
34    fn ensure_runtime(&self) -> Result<&Path> {
35        self.runtime.as_deref().ok_or_else(|| {
36            anyhow::anyhow!(
37                "C# support requires the `dotnet` CLI. Install the .NET SDK from https://dotnet.microsoft.com/download and ensure `dotnet` is on your PATH."
38            )
39        })
40    }
41
42    fn ensure_target_framework(&self) -> Result<&str> {
43        self.target_framework
44            .as_deref()
45            .ok_or_else(|| anyhow::anyhow!("Unable to detect installed .NET SDK target framework"))
46    }
47
48    fn prepare_source(&self, payload: &ExecutionPayload, dir: &Path) -> Result<PathBuf> {
49        let target = dir.join("Program.cs");
50        match payload {
51            ExecutionPayload::Inline { code } | ExecutionPayload::Stdin { code } => {
52                let mut contents = code.to_string();
53                if !contents.ends_with('\n') {
54                    contents.push('\n');
55                }
56                fs::write(&target, contents).with_context(|| {
57                    format!(
58                        "failed to write temporary C# source to {}",
59                        target.display()
60                    )
61                })?;
62            }
63            ExecutionPayload::File { path } => {
64                fs::copy(path, &target).with_context(|| {
65                    format!(
66                        "failed to copy C# source from {} to {}",
67                        path.display(),
68                        target.display()
69                    )
70                })?;
71            }
72        }
73        Ok(target)
74    }
75
76    fn write_project_file(&self, dir: &Path, tfm: &str) -> Result<PathBuf> {
77        let project_path = dir.join("Run.csproj");
78        let contents = format!(
79            r#"<Project Sdk="Microsoft.NET.Sdk">
80  <PropertyGroup>
81    <OutputType>Exe</OutputType>
82    <TargetFramework>{}</TargetFramework>
83    <ImplicitUsings>enable</ImplicitUsings>
84    <Nullable>disable</Nullable>
85        <NoWarn>CS0219;CS8321</NoWarn>
86  </PropertyGroup>
87</Project>
88"#,
89            tfm
90        );
91        fs::write(&project_path, contents).with_context(|| {
92            format!(
93                "failed to write temporary C# project file to {}",
94                project_path.display()
95            )
96        })?;
97        Ok(project_path)
98    }
99
100    fn run_project(
101        &self,
102        runtime: &Path,
103        project: &Path,
104        workdir: &Path,
105    ) -> Result<std::process::Output> {
106        let mut cmd = Command::new(runtime);
107        cmd.arg("run")
108            .arg("--project")
109            .arg(project)
110            .arg("--nologo")
111            .stdout(Stdio::piped())
112            .stderr(Stdio::piped())
113            .current_dir(workdir);
114        cmd.stdin(Stdio::inherit());
115        cmd.env("DOTNET_CLI_TELEMETRY_OPTOUT", "1");
116        cmd.env("DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "1");
117        cmd.output().with_context(|| {
118            format!(
119                "failed to execute dotnet run for project {} using {}",
120                project.display(),
121                runtime.display()
122            )
123        })
124    }
125}
126
127impl LanguageEngine for CSharpEngine {
128    fn id(&self) -> &'static str {
129        "csharp"
130    }
131
132    fn display_name(&self) -> &'static str {
133        "C#"
134    }
135
136    fn aliases(&self) -> &[&'static str] {
137        &["cs", "c#", "dotnet"]
138    }
139
140    fn supports_sessions(&self) -> bool {
141        self.runtime.is_some() && self.target_framework.is_some()
142    }
143
144    fn validate(&self) -> Result<()> {
145        let runtime = self.ensure_runtime()?;
146        let _tfm = self.ensure_target_framework()?;
147
148        let mut cmd = Command::new(runtime);
149        cmd.arg("--version")
150            .stdout(Stdio::null())
151            .stderr(Stdio::null());
152        cmd.status()
153            .with_context(|| format!("failed to invoke {}", runtime.display()))?
154            .success()
155            .then_some(())
156            .ok_or_else(|| anyhow::anyhow!("{} is not executable", runtime.display()))
157    }
158
159    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
160        let runtime = self.ensure_runtime()?;
161        let tfm = self.ensure_target_framework()?;
162
163        let build_dir = Builder::new()
164            .prefix("run-csharp")
165            .tempdir()
166            .context("failed to create temporary directory for csharp build")?;
167        let dir_path = build_dir.path();
168
169        self.write_project_file(dir_path, tfm)?;
170        self.prepare_source(payload, dir_path)?;
171
172        let project_path = dir_path.join("Run.csproj");
173        let start = Instant::now();
174
175        let output = self.run_project(runtime, &project_path, dir_path)?;
176
177        Ok(ExecutionOutcome {
178            language: self.id().to_string(),
179            exit_code: output.status.code(),
180            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
181            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
182            duration: start.elapsed(),
183        })
184    }
185
186    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
187        let runtime = self.ensure_runtime()?.to_path_buf();
188        let tfm = self.ensure_target_framework()?.to_string();
189
190        let dir = Builder::new()
191            .prefix("run-csharp-repl")
192            .tempdir()
193            .context("failed to create temporary directory for csharp repl")?;
194        let dir_path = dir.path();
195
196        let project_path = self.write_project_file(dir_path, &tfm)?;
197        let program_path = dir_path.join("Program.cs");
198        fs::write(&program_path, "// C# REPL session\n")
199            .with_context(|| format!("failed to initialize {}", program_path.display()))?;
200
201        Ok(Box::new(CSharpSession {
202            runtime,
203            dir,
204            project_path,
205            program_path,
206            snippets: Vec::new(),
207            previous_stdout: String::new(),
208            previous_stderr: String::new(),
209        }))
210    }
211}
212
213struct CSharpSession {
214    runtime: PathBuf,
215    dir: TempDir,
216    project_path: PathBuf,
217    program_path: PathBuf,
218    snippets: Vec<String>,
219    previous_stdout: String,
220    previous_stderr: String,
221}
222
223impl CSharpSession {
224    fn render_source(&self) -> String {
225        let mut source = String::from(
226            "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text;\nusing System.Threading.Tasks;\n#nullable disable\n\nstatic void __run_print(object value)\n{\n    if (value is null)\n    {\n        Console.WriteLine(\"null\");\n        return;\n    }\n\n    if (value is string s)\n    {\n        Console.WriteLine(s);\n        return;\n    }\n\n    // Pretty-print enumerables: [a, b, c]\n    if (value is System.Collections.IEnumerable enumerable && value is not string)\n    {\n        var sb = new StringBuilder();\n        sb.Append('[');\n        var first = true;\n        foreach (var item in enumerable)\n        {\n            if (!first) sb.Append(\", \");\n            first = false;\n            sb.Append(item is null ? \"null\" : item.ToString());\n        }\n        sb.Append(']');\n        Console.WriteLine(sb.ToString());\n        return;\n    }\n\n    Console.WriteLine(value);\n}\n",
227        );
228        for snippet in &self.snippets {
229            source.push_str(snippet);
230            if !snippet.ends_with('\n') {
231                source.push('\n');
232            }
233        }
234        source
235    }
236
237    fn write_source(&self, contents: &str) -> Result<()> {
238        fs::write(&self.program_path, contents).with_context(|| {
239            format!(
240                "failed to write generated C# REPL source to {}",
241                self.program_path.display()
242            )
243        })
244    }
245
246    fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
247        let source = self.render_source();
248        self.write_source(&source)?;
249
250        let output = run_dotnet_project(&self.runtime, &self.project_path, self.dir.path())?;
251        let stdout_full = String::from_utf8_lossy(&output.stdout).into_owned();
252        let stderr_full = String::from_utf8_lossy(&output.stderr).into_owned();
253
254        let stdout_delta = diff_output(&self.previous_stdout, &stdout_full);
255        let stderr_delta = diff_output(&self.previous_stderr, &stderr_full);
256
257        let success = output.status.success();
258        if success {
259            self.previous_stdout = stdout_full;
260            self.previous_stderr = stderr_full;
261        }
262
263        let outcome = ExecutionOutcome {
264            language: "csharp".to_string(),
265            exit_code: output.status.code(),
266            stdout: stdout_delta,
267            stderr: stderr_delta,
268            duration: start.elapsed(),
269        };
270
271        Ok((outcome, success))
272    }
273
274    fn run_snippet(&mut self, snippet: String) -> Result<ExecutionOutcome> {
275        self.snippets.push(snippet);
276        let start = Instant::now();
277        let (outcome, success) = self.run_current(start)?;
278        if !success {
279            let _ = self.snippets.pop();
280        }
281        Ok(outcome)
282    }
283
284    fn reset_state(&mut self) -> Result<()> {
285        self.snippets.clear();
286        self.previous_stdout.clear();
287        self.previous_stderr.clear();
288        let source = self.render_source();
289        self.write_source(&source)
290    }
291}
292
293impl LanguageSession for CSharpSession {
294    fn language_id(&self) -> &str {
295        "csharp"
296    }
297
298    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
299        let trimmed = code.trim();
300        if trimmed.is_empty() {
301            return Ok(ExecutionOutcome {
302                language: self.language_id().to_string(),
303                exit_code: None,
304                stdout: String::new(),
305                stderr: String::new(),
306                duration: Instant::now().elapsed(),
307            });
308        }
309
310        if trimmed.eq_ignore_ascii_case(":reset") {
311            self.reset_state()?;
312            return Ok(ExecutionOutcome {
313                language: self.language_id().to_string(),
314                exit_code: None,
315                stdout: String::new(),
316                stderr: String::new(),
317                duration: Duration::default(),
318            });
319        }
320
321        if trimmed.eq_ignore_ascii_case(":help") {
322            return Ok(ExecutionOutcome {
323                language: self.language_id().to_string(),
324                exit_code: None,
325                stdout:
326                    "C# commands:\n  :reset - clear session state\n  :help  - show this message\n"
327                        .to_string(),
328                stderr: String::new(),
329                duration: Duration::default(),
330            });
331        }
332
333        if should_treat_as_expression(trimmed) {
334            let snippet = wrap_expression(trimmed, self.snippets.len());
335            let outcome = self.run_snippet(snippet)?;
336            if outcome.exit_code.unwrap_or(0) == 0 {
337                return Ok(outcome);
338            }
339        }
340
341        let snippet = prepare_statement(code);
342        let outcome = self.run_snippet(snippet)?;
343        Ok(outcome)
344    }
345
346    fn shutdown(&mut self) -> Result<()> {
347        Ok(())
348    }
349}
350
351fn diff_output(previous: &str, current: &str) -> String {
352    if let Some(stripped) = current.strip_prefix(previous) {
353        stripped.to_string()
354    } else {
355        current.to_string()
356    }
357}
358
359fn should_treat_as_expression(code: &str) -> bool {
360    let trimmed = code.trim();
361    if trimmed.is_empty() {
362        return false;
363    }
364    if trimmed.contains('\n') {
365        return false;
366    }
367
368    let trimmed = trimmed.trim_end();
369    let without_trailing_semicolon = trimmed.strip_suffix(';').unwrap_or(trimmed).trim_end();
370    if without_trailing_semicolon.is_empty() {
371        return false;
372    }
373    if without_trailing_semicolon.contains(';') {
374        return false;
375    }
376
377    let lowered = without_trailing_semicolon.to_ascii_lowercase();
378    const KEYWORDS: [&str; 17] = [
379        "using ",
380        "namespace ",
381        "class ",
382        "struct ",
383        "record ",
384        "enum ",
385        "interface ",
386        "public ",
387        "private ",
388        "protected ",
389        "internal ",
390        "static ",
391        "if ",
392        "for ",
393        "while ",
394        "switch ",
395        "try ",
396    ];
397    if KEYWORDS.iter().any(|kw| lowered.starts_with(kw)) {
398        return false;
399    }
400    if lowered.starts_with("return ") || lowered.starts_with("throw ") {
401        return false;
402    }
403    if without_trailing_semicolon.starts_with("Console.")
404        || without_trailing_semicolon.starts_with("System.Console.")
405    {
406        return false;
407    }
408
409    if lowered.starts_with("new ") {
410        return true;
411    }
412
413    if without_trailing_semicolon.contains("++") || without_trailing_semicolon.contains("--") {
414        return false;
415    }
416
417    if without_trailing_semicolon.contains('=')
418        && !without_trailing_semicolon.contains("==")
419        && !without_trailing_semicolon.contains("!=")
420        && !without_trailing_semicolon.contains("<=")
421        && !without_trailing_semicolon.contains(">=")
422        && !without_trailing_semicolon.contains("=>")
423    {
424        return false;
425    }
426
427    const DECL_PREFIXES: [&str; 19] = [
428        "var ", "bool ", "byte ", "sbyte ", "char ", "short ", "ushort ", "int ", "uint ", "long ",
429        "ulong ", "float ", "double ", "decimal ", "string ", "object ", "dynamic ", "nint ",
430        "nuint ",
431    ];
432    if DECL_PREFIXES
433        .iter()
434        .any(|prefix| lowered.starts_with(prefix))
435    {
436        return false;
437    }
438
439    let expr = without_trailing_semicolon;
440
441    if expr == "true" || expr == "false" {
442        return true;
443    }
444    if expr.parse::<f64>().is_ok() {
445        return true;
446    }
447    if (expr.starts_with('"') || expr.starts_with("$\"")) && expr.ends_with('"') && expr.len() >= 2
448    {
449        return true;
450    }
451    if expr.starts_with('\'') && expr.ends_with('\'') && expr.len() >= 2 {
452        return true;
453    }
454
455    if expr.contains('(') && expr.ends_with(')') {
456        return true;
457    }
458
459    if expr.contains('[') && expr.ends_with(']') {
460        return true;
461    }
462
463    if expr.contains('.')
464        && expr
465            .chars()
466            .all(|c| !c.is_whitespace() && c != '{' && c != '}' && c != ';')
467        && expr
468            .chars()
469            .last()
470            .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
471    {
472        return true;
473    }
474
475    if expr.contains("==")
476        || expr.contains("!=")
477        || expr.contains("<=")
478        || expr.contains(">=")
479        || expr.contains("&&")
480        || expr.contains("||")
481    {
482        return true;
483    }
484    if expr.contains('?') && expr.contains(':') {
485        return true;
486    }
487    if expr.chars().any(|c| "+-*/%<>^|&".contains(c)) {
488        return true;
489    }
490
491    if expr
492        .chars()
493        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
494    {
495        return true;
496    }
497
498    false
499}
500
501fn wrap_expression(code: &str, index: usize) -> String {
502    let expr = code.trim().trim_end_matches(';').trim_end();
503    let expr = match expr {
504        "null" => "(object)null",
505        "default" => "(object)null",
506        other => other,
507    };
508    format!("var __repl_val_{index} = ({expr});\n__run_print(__repl_val_{index});\n")
509}
510
511fn prepare_statement(code: &str) -> String {
512    let trimmed_end = code.trim_end_matches(['\r', '\n']);
513    if trimmed_end.contains('\n') {
514        let mut snippet = trimmed_end.to_string();
515        if !snippet.ends_with('\n') {
516            snippet.push('\n');
517        }
518        return snippet;
519    }
520
521    let line = trimmed_end.trim();
522    if line.is_empty() {
523        return "\n".to_string();
524    }
525
526    let lowered = line.to_ascii_lowercase();
527    let starts_with_control = [
528        "if ",
529        "for ",
530        "while ",
531        "switch ",
532        "try",
533        "catch",
534        "finally",
535        "else",
536        "do",
537        "using ",
538        "namespace ",
539        "class ",
540        "struct ",
541        "record ",
542        "enum ",
543        "interface ",
544    ]
545    .iter()
546    .any(|kw| lowered.starts_with(kw));
547
548    let looks_like_expr_stmt = line.ends_with("++")
549        || line.ends_with("--")
550        || line.starts_with("++")
551        || line.starts_with("--")
552        || line.contains('=')
553        || (line.contains('(') && line.ends_with(')'));
554
555    let mut snippet = String::new();
556    snippet.push_str(line);
557    if !line.ends_with(';') && !starts_with_control && looks_like_expr_stmt {
558        snippet.push(';');
559    }
560    snippet.push('\n');
561    snippet
562}
563
564fn resolve_dotnet_runtime() -> Option<PathBuf> {
565    which::which("dotnet").ok()
566}
567
568fn detect_target_framework(dotnet: &Path) -> Result<String> {
569    let output = Command::new(dotnet)
570        .arg("--list-sdks")
571        .stdout(Stdio::piped())
572        .stderr(Stdio::null())
573        .output()
574        .with_context(|| format!("failed to query SDKs via {}", dotnet.display()))?;
575
576    if !output.status.success() {
577        bail!(
578            "{} --list-sdks exited with status {}",
579            dotnet.display(),
580            output.status
581        );
582    }
583
584    let stdout = String::from_utf8_lossy(&output.stdout);
585    let mut best: Option<(u32, u32, String)> = None;
586
587    for line in stdout.lines() {
588        let version = line.split_whitespace().next().unwrap_or("");
589        if version.is_empty() {
590            continue;
591        }
592        if let Some((major, minor)) = parse_version(version) {
593            let tfm = format!("net{}.{}", major, minor);
594            match &best {
595                Some((b_major, b_minor, _)) if (*b_major, *b_minor) >= (major, minor) => {}
596                _ => best = Some((major, minor, tfm)),
597            }
598        }
599    }
600
601    best.map(|(_, _, tfm)| tfm).ok_or_else(|| {
602        anyhow::anyhow!("unable to infer target framework from dotnet --list-sdks output")
603    })
604}
605
606fn parse_version(version: &str) -> Option<(u32, u32)> {
607    let mut parts = version.split('.');
608    let major = parts.next()?.parse().ok()?;
609    let minor = parts.next().unwrap_or("0").parse().ok()?;
610    Some((major, minor))
611}
612
613fn run_dotnet_project(
614    runtime: &Path,
615    project: &Path,
616    workdir: &Path,
617) -> Result<std::process::Output> {
618    let mut cmd = Command::new(runtime);
619    cmd.arg("run")
620        .arg("--project")
621        .arg(project)
622        .arg("--nologo")
623        .stdout(Stdio::piped())
624        .stderr(Stdio::piped())
625        .current_dir(workdir);
626    cmd.env("DOTNET_CLI_TELEMETRY_OPTOUT", "1");
627    cmd.env("DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "1");
628    cmd.output().with_context(|| {
629        format!(
630            "failed to execute dotnet run for project {} using {}",
631            project.display(),
632            runtime.display()
633        )
634    })
635}