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        // 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
363
364    let trimmed = trimmed.trim_end();
365    let without_trailing_semicolon = trimmed.strip_suffix(';').unwrap_or(trimmed).trim_end();
366    if without_trailing_semicolon.is_empty() {
367        return false;
368    }
369    if without_trailing_semicolon.contains(';') {
370        return false;
371    }
372
373    let lowered = without_trailing_semicolon.to_ascii_lowercase();
374    const KEYWORDS: [&str; 17] = [
375        "using ",
376        "namespace ",
377        "class ",
378        "struct ",
379        "record ",
380        "enum ",
381        "interface ",
382        "public ",
383        "private ",
384        "protected ",
385        "internal ",
386        "static ",
387        "if ",
388        "for ",
389        "while ",
390        "switch ",
391        "try ",
392    ];
393    if KEYWORDS.iter().any(|kw| lowered.starts_with(kw)) {
394        return false;
395    }
396    if lowered.starts_with("return ") || lowered.starts_with("throw ") {
397        return false;
398    }
399    if without_trailing_semicolon.starts_with("Console.")
400        || without_trailing_semicolon.starts_with("System.Console.")
401    {
402        return false;
403    }
404
405
406    if lowered.starts_with("new ") {
407        return true;
408    }
409
410    if without_trailing_semicolon.contains("++") || without_trailing_semicolon.contains("--") {
411        return false;
412    }
413
414    if without_trailing_semicolon.contains('=')
415        && !without_trailing_semicolon.contains("==")
416        && !without_trailing_semicolon.contains("!=")
417        && !without_trailing_semicolon.contains("<=")
418        && !without_trailing_semicolon.contains(">=")
419        && !without_trailing_semicolon.contains("=>")
420    {
421        return false;
422    }
423
424    const DECL_PREFIXES: [&str; 19] = [
425        "var ", "bool ", "byte ", "sbyte ", "char ", "short ", "ushort ", "int ", "uint ", "long ",
426        "ulong ", "float ", "double ", "decimal ", "string ", "object ", "dynamic ", "nint ",
427        "nuint ",
428    ];
429    if DECL_PREFIXES.iter().any(|prefix| lowered.starts_with(prefix)) {
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        return true;
443    }
444    if expr.starts_with('\'') && expr.ends_with('\'') && expr.len() >= 2 {
445        return true;
446    }
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        // `var x = null;` is not legal in C# because the type can't be inferred.
499        "null" => "(object)null",
500        // `var x = default;` also has no target type.
501        "default" => "(object)null",
502        other => other,
503    };
504    format!("var __repl_val_{index} = ({expr});\n__run_print(__repl_val_{index});\n")
505}
506
507fn prepare_statement(code: &str) -> String {
508    let trimmed_end = code.trim_end_matches(['\r', '\n']);
509    if trimmed_end.contains('\n') {
510        let mut snippet = trimmed_end.to_string();
511        if !snippet.ends_with('\n') {
512            snippet.push('\n');
513        }
514        return snippet;
515    }
516
517    let line = trimmed_end.trim();
518    if line.is_empty() {
519        return "\n".to_string();
520    }
521
522
523    let lowered = line.to_ascii_lowercase();
524    let starts_with_control = [
525        "if ", "for ", "while ", "switch ", "try", "catch", "finally", "else", "do", "using ",
526        "namespace ", "class ", "struct ", "record ", "enum ", "interface ",
527    ]
528    .iter()
529    .any(|kw| lowered.starts_with(kw));
530
531    let looks_like_expr_stmt = line.ends_with("++")
532        || line.ends_with("--")
533        || line.starts_with("++")
534        || line.starts_with("--")
535        || line.contains('=')
536        || (line.contains('(') && line.ends_with(')'));
537
538    let mut snippet = String::new();
539    snippet.push_str(line);
540    if !line.ends_with(';') && !starts_with_control && looks_like_expr_stmt {
541        snippet.push(';');
542    }
543    snippet.push('\n');
544    snippet
545}
546
547fn resolve_dotnet_runtime() -> Option<PathBuf> {
548    which::which("dotnet").ok()
549}
550
551fn detect_target_framework(dotnet: &Path) -> Result<String> {
552    let output = Command::new(dotnet)
553        .arg("--list-sdks")
554        .stdout(Stdio::piped())
555        .stderr(Stdio::null())
556        .output()
557        .with_context(|| format!("failed to query SDKs via {}", dotnet.display()))?;
558
559    if !output.status.success() {
560        bail!(
561            "{} --list-sdks exited with status {}",
562            dotnet.display(),
563            output.status
564        );
565    }
566
567    let stdout = String::from_utf8_lossy(&output.stdout);
568    let mut best: Option<(u32, u32, String)> = None;
569
570    for line in stdout.lines() {
571        let version = line.split_whitespace().next().unwrap_or("");
572        if version.is_empty() {
573            continue;
574        }
575        if let Some((major, minor)) = parse_version(version) {
576            let tfm = format!("net{}.{}", major, minor);
577            match &best {
578                Some((b_major, b_minor, _)) if (*b_major, *b_minor) >= (major, minor) => {}
579                _ => best = Some((major, minor, tfm)),
580            }
581        }
582    }
583
584    best.map(|(_, _, tfm)| tfm).ok_or_else(|| {
585        anyhow::anyhow!("unable to infer target framework from dotnet --list-sdks output")
586    })
587}
588
589fn parse_version(version: &str) -> Option<(u32, u32)> {
590    let mut parts = version.split('.');
591    let major = parts.next()?.parse().ok()?;
592    let minor = parts.next().unwrap_or("0").parse().ok()?;
593    Some((major, minor))
594}
595
596fn run_dotnet_project(
597    runtime: &Path,
598    project: &Path,
599    workdir: &Path,
600) -> Result<std::process::Output> {
601    let mut cmd = Command::new(runtime);
602    cmd.arg("run")
603        .arg("--project")
604        .arg(project)
605        .arg("--nologo")
606        .stdout(Stdio::piped())
607        .stderr(Stdio::piped())
608        .current_dir(workdir);
609    cmd.env("DOTNET_CLI_TELEMETRY_OPTOUT", "1");
610    cmd.env("DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "1");
611    cmd.output().with_context(|| {
612        format!(
613            "failed to execute dotnet run for project {} using {}",
614            project.display(),
615            runtime.display()
616        )
617    })
618}