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(())
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 "null" => "(object)null",
500 "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}