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 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.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",
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        Ok(())
342    }
343}
344
345fn diff_output(previous: &str, current: &str) -> String {
346    if let Some(stripped) = current.strip_prefix(previous) {
347        stripped.to_string()
348    } else {
349        current.to_string()
350    }
351}
352
353fn should_treat_as_expression(code: &str) -> bool {
354    let trimmed = code.trim();
355    if trimmed.is_empty() {
356        return false;
357    }
358    if trimmed.contains('\n') {
359        return false;
360    }
361
362    let trimmed = trimmed.trim_end();
363    let without_trailing_semicolon = trimmed.strip_suffix(';').unwrap_or(trimmed).trim_end();
364    if without_trailing_semicolon.is_empty() {
365        return false;
366    }
367    if without_trailing_semicolon.contains(';') {
368        return false;
369    }
370
371    let lowered = without_trailing_semicolon.to_ascii_lowercase();
372    const KEYWORDS: [&str; 17] = [
373        "using ",
374        "namespace ",
375        "class ",
376        "struct ",
377        "record ",
378        "enum ",
379        "interface ",
380        "public ",
381        "private ",
382        "protected ",
383        "internal ",
384        "static ",
385        "if ",
386        "for ",
387        "while ",
388        "switch ",
389        "try ",
390    ];
391    if KEYWORDS.iter().any(|kw| lowered.starts_with(kw)) {
392        return false;
393    }
394    if lowered.starts_with("return ") || lowered.starts_with("throw ") {
395        return false;
396    }
397    if without_trailing_semicolon.starts_with("Console.")
398        || without_trailing_semicolon.starts_with("System.Console.")
399    {
400        return false;
401    }
402
403    if lowered.starts_with("new ") {
404        return true;
405    }
406
407    if without_trailing_semicolon.contains("++") || without_trailing_semicolon.contains("--") {
408        return false;
409    }
410
411    if without_trailing_semicolon.contains('=')
412        && !without_trailing_semicolon.contains("==")
413        && !without_trailing_semicolon.contains("!=")
414        && !without_trailing_semicolon.contains("<=")
415        && !without_trailing_semicolon.contains(">=")
416        && !without_trailing_semicolon.contains("=>")
417    {
418        return false;
419    }
420
421    const DECL_PREFIXES: [&str; 19] = [
422        "var ", "bool ", "byte ", "sbyte ", "char ", "short ", "ushort ", "int ", "uint ", "long ",
423        "ulong ", "float ", "double ", "decimal ", "string ", "object ", "dynamic ", "nint ",
424        "nuint ",
425    ];
426    if DECL_PREFIXES
427        .iter()
428        .any(|prefix| lowered.starts_with(prefix))
429    {
430        return false;
431    }
432
433    let expr = without_trailing_semicolon;
434
435    if expr == "true" || expr == "false" {
436        return true;
437    }
438    if expr.parse::<f64>().is_ok() {
439        return true;
440    }
441    if (expr.starts_with('"') || expr.starts_with("$\"")) && expr.ends_with('"') && expr.len() >= 2
442    {
443        return true;
444    }
445    if expr.starts_with('\'') && expr.ends_with('\'') && expr.len() >= 2 {
446        return true;
447    }
448
449    if expr.contains('(') && expr.ends_with(')') {
450        return true;
451    }
452
453    if expr.contains('[') && expr.ends_with(']') {
454        return true;
455    }
456
457    if expr.contains('.')
458        && expr
459            .chars()
460            .all(|c| !c.is_whitespace() && c != '{' && c != '}' && c != ';')
461        && expr
462            .chars()
463            .last()
464            .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
465    {
466        return true;
467    }
468
469    if expr.contains("==")
470        || expr.contains("!=")
471        || expr.contains("<=")
472        || expr.contains(">=")
473        || expr.contains("&&")
474        || expr.contains("||")
475    {
476        return true;
477    }
478    if expr.contains('?') && expr.contains(':') {
479        return true;
480    }
481    if expr.chars().any(|c| "+-*/%<>^|&".contains(c)) {
482        return true;
483    }
484
485    if expr
486        .chars()
487        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
488    {
489        return true;
490    }
491
492    false
493}
494
495fn wrap_expression(code: &str, index: usize) -> String {
496    let expr = code.trim().trim_end_matches(';').trim_end();
497    let expr = match expr {
498        "null" => "(object)null",
499        "default" => "(object)null",
500        other => other,
501    };
502    format!("var __repl_val_{index} = ({expr});\n__run_print(__repl_val_{index});\n")
503}
504
505fn prepare_statement(code: &str) -> String {
506    let trimmed_end = code.trim_end_matches(['\r', '\n']);
507    if trimmed_end.contains('\n') {
508        let mut snippet = trimmed_end.to_string();
509        if !snippet.ends_with('\n') {
510            snippet.push('\n');
511        }
512        return snippet;
513    }
514
515    let line = trimmed_end.trim();
516    if line.is_empty() {
517        return "\n".to_string();
518    }
519
520    let lowered = line.to_ascii_lowercase();
521    let starts_with_control = [
522        "if ",
523        "for ",
524        "while ",
525        "switch ",
526        "try",
527        "catch",
528        "finally",
529        "else",
530        "do",
531        "using ",
532        "namespace ",
533        "class ",
534        "struct ",
535        "record ",
536        "enum ",
537        "interface ",
538    ]
539    .iter()
540    .any(|kw| lowered.starts_with(kw));
541
542    let looks_like_expr_stmt = line.ends_with("++")
543        || line.ends_with("--")
544        || line.starts_with("++")
545        || line.starts_with("--")
546        || line.contains('=')
547        || (line.contains('(') && line.ends_with(')'));
548
549    let mut snippet = String::new();
550    snippet.push_str(line);
551    if !line.ends_with(';') && !starts_with_control && looks_like_expr_stmt {
552        snippet.push(';');
553    }
554    snippet.push('\n');
555    snippet
556}
557
558fn resolve_dotnet_runtime() -> Option<PathBuf> {
559    which::which("dotnet").ok()
560}
561
562fn detect_target_framework(dotnet: &Path) -> Result<String> {
563    let output = Command::new(dotnet)
564        .arg("--list-sdks")
565        .stdout(Stdio::piped())
566        .stderr(Stdio::null())
567        .output()
568        .with_context(|| format!("failed to query SDKs via {}", dotnet.display()))?;
569
570    if !output.status.success() {
571        bail!(
572            "{} --list-sdks exited with status {}",
573            dotnet.display(),
574            output.status
575        );
576    }
577
578    let stdout = String::from_utf8_lossy(&output.stdout);
579    let mut best: Option<(u32, u32, String)> = None;
580
581    for line in stdout.lines() {
582        let version = line.split_whitespace().next().unwrap_or("");
583        if version.is_empty() {
584            continue;
585        }
586        if let Some((major, minor)) = parse_version(version) {
587            let tfm = format!("net{}.{}", major, minor);
588            match &best {
589                Some((b_major, b_minor, _)) if (*b_major, *b_minor) >= (major, minor) => {}
590                _ => best = Some((major, minor, tfm)),
591            }
592        }
593    }
594
595    best.map(|(_, _, tfm)| tfm).ok_or_else(|| {
596        anyhow::anyhow!("unable to infer target framework from dotnet --list-sdks output")
597    })
598}
599
600fn parse_version(version: &str) -> Option<(u32, u32)> {
601    let mut parts = version.split('.');
602    let major = parts.next()?.parse().ok()?;
603    let minor = parts.next().unwrap_or("0").parse().ok()?;
604    Some((major, minor))
605}
606
607fn run_dotnet_project(
608    runtime: &Path,
609    project: &Path,
610    workdir: &Path,
611) -> Result<std::process::Output> {
612    let mut cmd = Command::new(runtime);
613    cmd.arg("run")
614        .arg("--project")
615        .arg(project)
616        .arg("--nologo")
617        .stdout(Stdio::piped())
618        .stderr(Stdio::piped())
619        .current_dir(workdir);
620    cmd.env("DOTNET_CLI_TELEMETRY_OPTOUT", "1");
621    cmd.env("DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "1");
622    cmd.output().with_context(|| {
623        format!(
624            "failed to execute dotnet run for project {} using {}",
625            project.display(),
626            runtime.display()
627        )
628    })
629}