1use std::path::{Path, PathBuf};
26use std::process::{Command, Stdio};
27use std::rc::Rc;
28use std::sync::Arc;
29
30use gpui_kit::assets::IconName;
31use gpui_kit::component::button::{Button, ButtonVariants as _};
32use gpui_kit::component::{Disableable as _, Selectable as _, Sizable as _, h_flex, v_flex};
33use gpui_kit::prelude::FluentBuilder as _;
34use gpui_kit::{
35 App, AppContext as _, Context, Entity, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString,
36 StatefulInteractiveElement as _, Styled as _, Subscription, WeakEntity, Window, div, px,
37};
38use kitchen_ui::theme::{self, color};
39use kitchen_ui::widgets::{CodeChanged, CodeLanguage, CodeView, EmptyState};
40use serde_json::{Value, json};
41
42use crate::command::{CommandSpec, Registry};
43use crate::jobs;
44
45#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct Port {
48 pub name: String,
50 pub ty: String,
52}
53
54#[derive(Clone, Debug, Default, PartialEq)]
56pub struct ScriptMeta {
57 pub command: Option<String>,
59 pub hint: Option<String>,
61 pub args: Option<Value>,
63 pub node: Option<(Vec<Port>, Vec<Port>)>,
65 pub problems: Vec<(usize, String)>,
67}
68
69impl ScriptMeta {
70 pub fn parse(text: &str) -> ScriptMeta {
73 let mut m = ScriptMeta::default();
74 for (i, line) in text.lines().enumerate() {
75 let t = line.trim();
76 if t.is_empty() || t.starts_with("#!") {
77 continue;
78 }
79 let Some(comment) = t.strip_prefix('#').or_else(|| t.strip_prefix("//")) else { break };
80 let Some(rest) = comment.trim().strip_prefix("kitchen:") else { continue };
81 let Some((key, value)) = rest.split_once('=') else {
82 m.problems.push((i + 1, "expected `kitchen: key = value`".into()));
83 continue;
84 };
85 let value = value.trim();
86 match key.trim() {
87 "command" => m.command = Some(value.to_string()),
88 "hint" => m.hint = Some(value.to_string()),
89 "args" => match serde_json::from_str::<Value>(value) {
90 Ok(v) if v.is_object() => m.args = Some(v),
91 Ok(_) => m.problems.push((i + 1, "args: a JSON Schema object".into())),
92 Err(e) => m.problems.push((i + 1, format!("args: {e}"))),
93 },
94 "node" => match parse_node(value) {
95 Ok(n) => m.node = Some(n),
96 Err(e) => m.problems.push((i + 1, format!("node: {e}"))),
97 },
98 other => m.problems.push((i + 1, format!("unknown key {other:?} (command, hint, args, node)"))),
99 }
100 }
101 m
102 }
103}
104
105fn parse_node(text: &str) -> Result<(Vec<Port>, Vec<Port>), String> {
107 let ports = |list: &str| -> Result<Vec<Port>, String> {
108 list.split(',').map(str::trim).filter(|p| !p.is_empty()).map(|p| match p.split_once(':') {
109 Some((n, t)) if !n.trim().is_empty() && !t.trim().is_empty() => Ok(Port { name: n.trim().into(), ty: t.trim().into() }),
110 _ => Err(format!("{p:?}: expected name:type")),
111 }).collect()
112 };
113 let (mut inputs, mut outputs) = (Vec::new(), Vec::new());
114 for part in text.split(';').map(str::trim).filter(|p| !p.is_empty()) {
115 if let Some(l) = part.strip_prefix("in ") {
116 inputs = ports(l)?;
117 } else if let Some(l) = part.strip_prefix("out ") {
118 outputs = ports(l)?;
119 } else {
120 return Err(format!("{part:?}: expected `in …` or `out …`"));
121 }
122 }
123 if outputs.is_empty() {
124 return Err("a node needs at least one output".into());
125 }
126 Ok((inputs, outputs))
127}
128
129#[derive(Clone, Debug, PartialEq)]
131pub struct Script {
132 pub path: PathBuf,
134 pub language: CodeLanguage,
136 pub meta: ScriptMeta,
138}
139
140impl Script {
141 pub fn load(path: &Path) -> Option<Script> {
143 let language = CodeLanguage::from_name(path.extension()?.to_str()?);
144 if !matches!(language, CodeLanguage::Python | CodeLanguage::Rust) {
145 return None;
146 }
147 let text = std::fs::read_to_string(path).ok()?;
148 Some(Script { path: path.to_path_buf(), language, meta: ScriptMeta::parse(&text) })
149 }
150
151 pub fn name(&self) -> String {
153 self.path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()
154 }
155
156 pub fn command_id(&self) -> Option<String> {
158 self.meta.command.as_ref().map(|_| format!("script.{}", self.name()))
159 }
160}
161
162#[derive(Clone, Debug, Default, PartialEq)]
164pub struct RunOutput {
165 pub ok: bool,
167 pub stdout: String,
169 pub stderr: String,
171 pub result: Option<Value>,
173 pub errors: Vec<(usize, String)>,
175}
176
177pub type Runner = Arc<dyn Fn(&Script, &Value) -> Result<RunOutput, String> + Send + Sync>;
179
180pub fn default_runner() -> Runner {
183 Arc::new(|script: &Script, input: &Value| {
184 let mut cmd = match script.language {
185 CodeLanguage::Python => {
186 let mut c = Command::new(if cfg!(windows) { "python" } else { "python3" });
187 c.arg("-u").arg(&script.path);
188 c
189 }
190 CodeLanguage::Rust => {
191 let mut c = Command::new("rust-script");
192 c.arg(&script.path);
193 c
194 }
195 other => return Err(format!("{} scripts do not run", other.name())),
196 };
197 let mut child = cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| match script.language {
198 CodeLanguage::Rust => format!("Rust scripts run with rust-script ({e}): cargo install rust-script"),
199 _ => format!("python3: {e}"),
200 })?;
201 if let Some(mut stdin) = child.stdin.take() {
202 use std::io::Write as _;
203 let _ = writeln!(stdin, "{input}");
204 }
205 let out = child.wait_with_output().map_err(|e| e.to_string())?;
206 let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned());
207 let result = stdout.lines().rev().find(|l| !l.trim().is_empty()).and_then(|l| serde_json::from_str::<Value>(l.trim()).ok()).filter(|v| v.is_object() || v.is_array());
208 let errors = script_errors(script, &stderr);
209 Ok(RunOutput { ok: out.status.success(), stdout, stderr, result, errors })
210 })
211}
212
213pub fn script_errors(script: &Script, stderr: &str) -> Vec<(usize, String)> {
216 let file = script.path.file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
217 let last = stderr.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim().to_string();
218 let mut out = Vec::new();
219 match script.language {
220 CodeLanguage::Python => {
221 let line = stderr
222 .lines()
223 .filter(|l| l.contains("File \"") && l.contains(&file))
224 .filter_map(|l| l.split(", line ").nth(1)?.split(|c: char| !c.is_ascii_digit()).next()?.parse::<usize>().ok())
225 .last();
226 if let Some(n) = line {
227 out.push((n, last));
228 }
229 }
230 CodeLanguage::Rust => {
231 let mut message = String::new();
232 for l in stderr.lines() {
233 if l.starts_with("error") {
234 message = l.trim().to_string();
235 } else if let Some(at) = l.trim().strip_prefix("--> ")
236 && at.contains(&file)
237 && let Some(n) = at.split(':').nth(1).and_then(|n| n.parse::<usize>().ok())
238 {
239 out.push((n, message.clone()));
240 }
241 }
242 }
243 _ => {}
244 }
245 out
246}
247
248#[derive(Clone, Debug, PartialEq)]
250pub struct NodeSpec {
251 pub id: String,
253 pub title: String,
255 pub inputs: Vec<Port>,
257 pub outputs: Vec<Port>,
259 pub script: PathBuf,
261}
262
263#[derive(Clone, Debug, PartialEq)]
265pub enum ScriptsEvent {
266 Finished {
268 script: PathBuf,
270 input: Value,
272 output: RunOutput,
274 },
275 ScriptsChanged,
277}
278
279pub type ContextFn = Rc<dyn Fn(&App) -> Value>;
281
282pub struct ScriptsWorkspace {
284 dir: PathBuf,
285 port_types: Vec<String>,
286 runner: Runner,
287 context: ContextFn,
288 scripts: Vec<Script>,
289 selected: Option<usize>,
290 editor: Option<(Entity<CodeView>, Subscription)>,
291 dirty: bool,
293 running: Option<u64>,
294 output: Option<RunOutput>,
295 message: Option<(bool, String)>,
296 registered: Vec<String>,
297}
298
299impl EventEmitter<ScriptsEvent> for ScriptsWorkspace {}
300
301impl ScriptsWorkspace {
302 pub fn new(dir: impl Into<PathBuf>, port_types: Vec<String>, context: impl Fn(&App) -> Value + 'static, window: &mut Window, cx: &mut Context<Self>) -> Self {
305 let dir = dir.into();
306 let _ = std::fs::create_dir_all(&dir);
307 let mut ws = Self {
308 dir,
309 port_types,
310 runner: default_runner(),
311 context: Rc::new(context),
312 scripts: Vec::new(),
313 selected: None,
314 editor: None,
315 dirty: false,
316 running: None,
317 output: None,
318 message: None,
319 registered: Vec::new(),
320 };
321 ws.rescan(cx);
322 if !ws.scripts.is_empty() {
323 ws.select(0, window, cx);
324 }
325 ws
326 }
327
328 pub fn set_runner(&mut self, runner: Runner) {
330 self.runner = runner;
331 }
332
333 pub fn scripts(&self) -> &[Script] {
335 &self.scripts
336 }
337
338 pub fn selected(&self) -> Option<&Script> {
340 self.selected.and_then(|i| self.scripts.get(i))
341 }
342
343 pub fn output(&self) -> Option<&RunOutput> {
345 self.output.as_ref()
346 }
347
348 pub fn is_running(&self) -> bool {
350 self.running.is_some()
351 }
352
353 pub fn editor(&self) -> Option<Entity<CodeView>> {
355 self.editor.as_ref().map(|(e, _)| e.clone())
356 }
357
358 pub fn rescan(&mut self, cx: &mut Context<Self>) {
360 let selected = self.selected().map(|s| s.path.clone());
361 let mut scripts: Vec<Script> = std::fs::read_dir(&self.dir).into_iter().flatten().flatten().filter_map(|e| Script::load(&e.path())).collect();
362 scripts.sort_by(|a, b| a.path.cmp(&b.path));
363 self.selected = selected.and_then(|p| scripts.iter().position(|s| s.path == p));
364 self.scripts = scripts;
365 cx.emit(ScriptsEvent::ScriptsChanged);
366 cx.notify();
367 }
368
369 pub fn select(&mut self, i: usize, window: &mut Window, cx: &mut Context<Self>) {
371 let Some(s) = self.scripts.get(i) else { return };
372 let text = std::fs::read_to_string(&s.path).unwrap_or_default();
373 let language = s.language;
374 let problems = s.meta.problems.clone();
375 let editor = cx.new(|cx| CodeView::new(language, text, window, cx).editable(true).errors(problems));
376 let sub = cx.subscribe(&editor, |this, _, _: &CodeChanged, cx| {
377 this.dirty = true;
378 cx.notify();
379 });
380 self.editor = Some((editor, sub));
381 self.selected = Some(i);
382 self.dirty = false;
383 self.output = None;
384 self.message = None;
385 cx.notify();
386 }
387
388 pub fn save(&mut self, cx: &mut Context<Self>) -> Result<(), String> {
390 let (Some(i), Some(editor)) = (self.selected, self.editor()) else { return Err("no script open".into()) };
391 let text = editor.read(cx).text(cx);
392 let path = self.scripts[i].path.clone();
393 std::fs::write(&path, &text).map_err(|e| format!("{}: {e}", path.display()))?;
394 self.dirty = false;
395 self.rescan(cx);
396 let problems = self.selected().map(|s| s.meta.problems.clone()).unwrap_or_default();
397 editor.update(cx, |e, cx| e.set_errors(problems, cx));
398 self.message = Some((false, format!("Saved {}", path.display())));
399 Ok(())
400 }
401
402 pub fn new_script(&mut self, language: CodeLanguage, window: &mut Window, cx: &mut Context<Self>) -> Result<PathBuf, String> {
404 let ext = if language == CodeLanguage::Rust { "rs" } else { "py" };
405 let path = (1..1000).map(|n| self.dir.join(format!("script_{n}.{ext}"))).find(|p| !p.exists()).ok_or("too many scripts")?;
406 let text = match language {
407 CodeLanguage::Rust => "// kitchen: command = My command\n// kitchen: hint = What it does\n\nuse std::io::Read;\n\nfn main() {\n let mut input = String::new();\n std::io::stdin().read_to_string(&mut input).unwrap();\n println!(\"{{\\\"ok\\\": true}}\");\n}\n",
408 _ => "# kitchen: command = My command\n# kitchen: hint = What it does\nimport json, sys\n\ninput = json.load(sys.stdin)\ncontext = input[\"context\"]\nprint(json.dumps({\"ok\": True}))\n",
409 };
410 std::fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display()))?;
411 self.rescan(cx);
412 if let Some(i) = self.scripts.iter().position(|s| s.path == path) {
413 self.select(i, window, cx);
414 }
415 Ok(path)
416 }
417
418 pub fn run_selected(&mut self, args: Value, cx: &mut Context<Self>) -> Result<(), String> {
420 if self.dirty {
421 self.save(cx)?;
422 }
423 let script = self.selected().cloned().ok_or("no script open")?;
424 self.run(script, args, cx)
425 }
426
427 pub fn run(&mut self, script: Script, args: Value, cx: &mut Context<Self>) -> Result<(), String> {
429 if self.running.is_some() {
430 return Err("a script is running".into());
431 }
432 let job = jobs::start("script", &format!("Running {}", script.name()), false)?;
433 self.running = Some(job.id);
434 self.message = None;
435 let input = json!({ "args": args, "context": (self.context)(cx) });
436 let runner = self.runner.clone();
437 let (s2, i2) = (script.clone(), input.clone());
438 cx.spawn(async move |this, cx| {
439 let out = cx.background_executor().spawn(async move { runner(&s2, &i2) }).await;
440 let cancelled = job.cancelled();
441 drop(job);
442 let _ = this.update(cx, |w, cx| {
443 w.running = None;
444 match out {
445 _ if cancelled => w.message = Some((true, "Stopped".into())),
446 Ok(output) => {
447 if let Some((editor, _)) = &w.editor
448 && w.selected().is_some_and(|s| s.path == script.path)
449 {
450 let errors = output.errors.clone();
451 editor.clone().update(cx, |e, cx| e.set_errors(errors, cx));
452 }
453 cx.emit(ScriptsEvent::Finished { script: script.path.clone(), input, output: output.clone() });
454 w.output = Some(output);
455 }
456 Err(e) => w.message = Some((true, e)),
457 }
458 cx.notify();
459 });
460 })
461 .detach();
462 cx.notify();
463 Ok(())
464 }
465
466 pub fn nodes(&self) -> (Vec<NodeSpec>, Vec<String>) {
469 let (mut ok, mut bad) = (Vec::new(), Vec::new());
470 for s in &self.scripts {
471 let Some((inputs, outputs)) = &s.meta.node else { continue };
472 let unknown: Vec<&str> = inputs.iter().chain(outputs).map(|p| p.ty.as_str()).filter(|t| !self.port_types.iter().any(|k| k == t)).collect();
473 if unknown.is_empty() {
474 ok.push(NodeSpec { id: format!("script.{}", s.name()), title: s.meta.command.clone().unwrap_or_else(|| s.name()), inputs: inputs.clone(), outputs: outputs.clone(), script: s.path.clone() });
475 } else {
476 bad.push(format!("{}: unknown port type {} (this app has {})", s.name(), unknown.join(", "), self.port_types.join(", ")));
477 }
478 }
479 (ok, bad)
480 }
481
482 pub fn register(&mut self, registry: &Registry, me: WeakEntity<Self>) {
485 for id in self.registered.drain(..) {
486 registry.remove(&id);
487 }
488 for s in &self.scripts {
489 let (Some(id), Some(title)) = (s.command_id(), s.meta.command.clone()) else { continue };
490 let (me1, script) = (me.clone(), s.clone());
491 let mut c = CommandSpec::raw(
492 id.clone(),
493 title,
494 if s.language == CodeLanguage::Rust { IconName::FileCode } else { IconName::FileText },
495 s.meta.args.clone(),
496 Rc::new(move |args, _, cx| {
497 let started = id_of(&script);
498 let script = script.clone();
499 me1.update(cx, |w, cx| w.run(script, args, cx)).map_err(|e| e.to_string())??;
500 Ok(json!({ "started": started }))
501 }),
502 )
503 .group("Scripts")
504 .hint(s.meta.hint.clone().unwrap_or_else(|| format!("Runs {}", s.path.display())));
505 let me2 = me.clone();
506 c = c.enabled(move |cx| match me2.upgrade().map(|w| w.read(cx).is_running()) {
507 Some(false) => Ok(()),
508 Some(true) => Err("A script is running".into()),
509 None => Err("The Scripts workspace is closed".into()),
510 });
511 registry.add(c);
512 self.registered.push(id);
513 }
514 }
515}
516
517fn id_of(s: &Script) -> String {
518 s.command_id().unwrap_or_else(|| s.name())
519}
520
521impl Render for ScriptsWorkspace {
522 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
523 let tree = v_flex()
524 .id("scripts-tree")
525 .w(px(240.))
526 .flex_none()
527 .h_full()
528 .overflow_y_scroll()
529 .p(px(theme::SM))
530 .gap(px(2.))
531 .border_r_1()
532 .border_color(color::border())
533 .child(
534 h_flex()
535 .gap(px(theme::XS))
536 .pb(px(theme::SM))
537 .child(Button::new("scripts-new-python").small().outline().icon(IconName::Plus).label("Python").tooltip("A new Python script").on_click(cx.listener(|w, _, window, cx| {
538 if let Err(e) = w.new_script(CodeLanguage::Python, window, cx) {
539 w.message = Some((true, e));
540 }
541 })))
542 .child(Button::new("scripts-new-rust").small().outline().icon(IconName::Plus).label("Rust").tooltip("A new Rust script (runs with rust-script)").on_click(cx.listener(|w, _, window, cx| {
543 if let Err(e) = w.new_script(CodeLanguage::Rust, window, cx) {
544 w.message = Some((true, e));
545 }
546 }))),
547 )
548 .children(self.scripts.iter().enumerate().map(|(i, s)| {
549 let kind = match (&s.meta.command, &s.meta.node) {
550 (Some(_), Some(_)) => "command · node",
551 (Some(_), None) => "command",
552 (None, Some(_)) => "node",
553 (None, None) => "script",
554 };
555 Button::new(SharedString::from(format!("script-{}", s.name())))
556 .ghost()
557 .small()
558 .w_full()
559 .justify_start()
560 .icon(if s.language == CodeLanguage::Rust { IconName::FileCode } else { IconName::FileText })
561 .child(h_flex().flex_1().min_w_0().gap(px(theme::SM)).child(div().flex_1().min_w_0().truncate().child(s.name())).child(div().text_xs().text_color(color::text_muted()).child(kind)))
562 .selected(self.selected == Some(i))
563 .on_click(cx.listener(move |w, _, window, cx| w.select(i, window, cx)))
564 }));
565 let running = self.running.is_some();
566 let toolbar = self.selected().map(|s| {
567 let what = match (&s.meta.command, &s.meta.node) {
568 (Some(c), Some((i, o))) => format!("Command \"{c}\" · node {} → {}", ports(i), ports(o)),
569 (Some(c), None) => format!("Command \"{c}\""),
570 (None, Some((i, o))) => format!("Node {} → {}", ports(i), ports(o)),
571 (None, None) => "A plain script (add `kitchen: command = …` to make it a command)".into(),
572 };
573 h_flex()
574 .flex_none()
575 .gap(px(theme::SM))
576 .px(px(theme::MD))
577 .py(px(theme::SM))
578 .items_center()
579 .border_b_1()
580 .border_color(color::border())
581 .child(div().text_sm().font_weight(FontWeight::SEMIBOLD).child(format!("{}{}", s.name(), if self.dirty { " •" } else { "" })))
582 .child(div().flex_1().min_w_0().truncate().text_xs().text_color(color::text_muted()).child(what))
583 .child(Button::new("scripts-save").small().outline().icon(IconName::Save).label("Save").disabled(!self.dirty).on_click(cx.listener(|w, _, _, cx| {
584 if let Err(e) = w.save(cx) {
585 w.message = Some((true, e));
586 }
587 })))
588 .child(Button::new("scripts-run").small().primary().icon(IconName::Play).label(if running { "Running…" } else { "Run" }).disabled(running).on_click(cx.listener(|w, _, _, cx| {
589 if let Err(e) = w.run_selected(Value::Null, cx) {
590 w.message = Some((true, e));
591 }
592 })))
593 });
594 let run_panel = v_flex()
595 .id("scripts-output")
596 .flex_none()
597 .h(px(200.))
598 .overflow_y_scroll()
599 .p(px(theme::MD))
600 .gap(px(theme::XS))
601 .border_t_1()
602 .border_color(color::border())
603 .bg(color::raised())
604 .child(div().text_xs().font_weight(FontWeight::SEMIBOLD).text_color(color::text_muted()).child("RUN"))
605 .children(self.message.clone().map(|(err, m)| div().text_sm().text_color(if err { color::danger() } else { color::text_muted() }).child(m)))
606 .when(running, |d| d.child(div().text_sm().text_color(color::accent_mark()).child("Running…")))
607 .children(self.output.clone().map(|o| {
608 v_flex()
609 .gap(px(theme::XS))
610 .child(div().text_sm().text_color(if o.ok { color::success() } else { color::danger() }).child(if o.ok { "Finished" } else { "Failed" }))
611 .children(o.result.map(|r| div().text_sm().font_family("monospace").child(format!("Result: {r}"))))
612 .children(o.stdout.lines().map(|l| div().text_sm().font_family("monospace").child(l.to_string())).collect::<Vec<_>>())
613 .children(o.stderr.lines().map(|l| div().text_sm().font_family("monospace").text_color(color::danger()).child(l.to_string())).collect::<Vec<_>>())
614 }));
615 let main = match &self.editor {
616 Some((editor, _)) => v_flex().flex_1().min_w_0().h_full().children(toolbar).child(div().flex_1().min_h_0().child(editor.clone())).child(run_panel).into_any_element(),
617 None => div()
618 .flex_1()
619 .child(EmptyState::new(IconName::SquareTerminal, "No script", format!("Scripts in {} become commands and pipeline nodes. Start one with Python or Rust.", self.dir.display())))
620 .into_any_element(),
621 };
622 h_flex().size_full().items_start().child(tree).child(main)
623 }
624}
625
626fn ports(p: &[Port]) -> String {
627 if p.is_empty() { "nothing".into() } else { p.iter().map(|p| format!("{}:{}", p.name, p.ty)).collect::<Vec<_>>().join(", ") }
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633
634 #[test]
635 fn headers_say_what_a_script_is() {
636 let m = ScriptMeta::parse(
637 "#!/usr/bin/env python3\n# kitchen: command = Count cells\n# kitchen: hint = Counts them\n# kitchen: args = {\"type\": \"object\"}\n# kitchen: node = in image:image, mask:mask; out counts:table\nimport json\n# kitchen: command = ignored after code\n",
638 );
639 assert_eq!(m.command.as_deref(), Some("Count cells"));
640 assert_eq!(m.hint.as_deref(), Some("Counts them"));
641 assert_eq!(m.args, Some(json!({"type": "object"})));
642 let (i, o) = m.node.unwrap();
643 assert_eq!(i, vec![Port { name: "image".into(), ty: "image".into() }, Port { name: "mask".into(), ty: "mask".into() }]);
644 assert_eq!(o, vec![Port { name: "counts".into(), ty: "table".into() }]);
645 assert!(m.problems.is_empty());
646
647 let bad = ScriptMeta::parse("// kitchen: args = [1]\n// kitchen: node = in a:image\n// kitchen: colour = red\n// kitchen: oops\nfn main() {}\n");
648 let lines: Vec<usize> = bad.problems.iter().map(|p| p.0).collect();
649 assert_eq!(lines, vec![1, 2, 3, 4]);
650 }
651
652 #[test]
653 fn errors_point_at_the_scripts_lines() {
654 let py = Script { path: "/x/count.py".into(), language: CodeLanguage::Python, meta: ScriptMeta::default() };
655 let tb = "Traceback (most recent call last):\n File \"/x/count.py\", line 7, in <module>\n main()\n File \"/x/count.py\", line 4, in main\n 1/0\nZeroDivisionError: division by zero\n";
656 assert_eq!(script_errors(&py, tb), vec![(4, "ZeroDivisionError: division by zero".to_string())]);
657 let rs = Script { path: "/x/count.rs".into(), language: CodeLanguage::Rust, meta: ScriptMeta::default() };
658 let msg = "error[E0308]: mismatched types\n --> /x/count.rs:6:13\n";
659 assert_eq!(script_errors(&rs, msg), vec![(6, "error[E0308]: mismatched types".to_string())]);
660 }
661}