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.Threading.Tasks;\n#nullable disable\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 if trimmed.ends_with(';') || trimmed.contains(';') {
363 return false;
364 }
365 let lowered = trimmed.to_ascii_lowercase();
366 const KEYWORDS: [&str; 17] = [
367 "using ",
368 "namespace ",
369 "class ",
370 "struct ",
371 "record ",
372 "enum ",
373 "interface ",
374 "public ",
375 "private ",
376 "protected ",
377 "internal ",
378 "static ",
379 "if ",
380 "for ",
381 "while ",
382 "switch ",
383 "try ",
384 ];
385 if KEYWORDS.iter().any(|kw| lowered.starts_with(kw)) {
386 return false;
387 }
388 if lowered.starts_with("return ") || lowered.starts_with("throw ") {
389 return false;
390 }
391 if trimmed.starts_with("Console.") || trimmed.starts_with("System.Console.") {
392 return false;
393 }
394
395 if trimmed == "true" || trimmed == "false" {
396 return true;
397 }
398 if trimmed.parse::<f64>().is_ok() {
399 return true;
400 }
401 if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2 {
402 return true;
403 }
404
405 if trimmed.contains("==")
406 || trimmed.contains("!=")
407 || trimmed.contains("<=")
408 || trimmed.contains(">=")
409 || trimmed.contains("&&")
410 || trimmed.contains("||")
411 {
412 return true;
413 }
414 if trimmed.chars().any(|c| "+-*/%<>^|&".contains(c)) {
415 return true;
416 }
417
418 if trimmed
419 .chars()
420 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
421 {
422 return true;
423 }
424
425 false
426}
427
428fn wrap_expression(code: &str, index: usize) -> String {
429 format!("var __repl_val_{index} = ({code});\nConsole.WriteLine(__repl_val_{index});\n")
430}
431
432fn prepare_statement(code: &str) -> String {
433 let mut snippet = code.to_string();
434 if !snippet.ends_with('\n') {
435 snippet.push('\n');
436 }
437 snippet
438}
439
440fn resolve_dotnet_runtime() -> Option<PathBuf> {
441 which::which("dotnet").ok()
442}
443
444fn detect_target_framework(dotnet: &Path) -> Result<String> {
445 let output = Command::new(dotnet)
446 .arg("--list-sdks")
447 .stdout(Stdio::piped())
448 .stderr(Stdio::null())
449 .output()
450 .with_context(|| format!("failed to query SDKs via {}", dotnet.display()))?;
451
452 if !output.status.success() {
453 bail!(
454 "{} --list-sdks exited with status {}",
455 dotnet.display(),
456 output.status
457 );
458 }
459
460 let stdout = String::from_utf8_lossy(&output.stdout);
461 let mut best: Option<(u32, u32, String)> = None;
462
463 for line in stdout.lines() {
464 let version = line.split_whitespace().next().unwrap_or("");
465 if version.is_empty() {
466 continue;
467 }
468 if let Some((major, minor)) = parse_version(version) {
469 let tfm = format!("net{}.{}", major, minor);
470 match &best {
471 Some((b_major, b_minor, _)) if (*b_major, *b_minor) >= (major, minor) => {}
472 _ => best = Some((major, minor, tfm)),
473 }
474 }
475 }
476
477 best.map(|(_, _, tfm)| tfm).ok_or_else(|| {
478 anyhow::anyhow!("unable to infer target framework from dotnet --list-sdks output")
479 })
480}
481
482fn parse_version(version: &str) -> Option<(u32, u32)> {
483 let mut parts = version.split('.');
484 let major = parts.next()?.parse().ok()?;
485 let minor = parts.next().unwrap_or("0").parse().ok()?;
486 Some((major, minor))
487}
488
489fn run_dotnet_project(
490 runtime: &Path,
491 project: &Path,
492 workdir: &Path,
493) -> Result<std::process::Output> {
494 let mut cmd = Command::new(runtime);
495 cmd.arg("run")
496 .arg("--project")
497 .arg(project)
498 .arg("--nologo")
499 .stdout(Stdio::piped())
500 .stderr(Stdio::piped())
501 .current_dir(workdir);
502 cmd.env("DOTNET_CLI_TELEMETRY_OPTOUT", "1");
503 cmd.env("DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "1");
504 cmd.output().with_context(|| {
505 format!(
506 "failed to execute dotnet run for project {} using {}",
507 project.display(),
508 runtime.display()
509 )
510 })
511}