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}