1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::time::{Duration, Instant};
5
6use anyhow::{bail, Context, Result};
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}