Skip to main content

runmat_runtime/builtins/introspection/
debugging.rs

1use runmat_builtins::{
2    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
3    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
4    CellArray, StructValue, Tensor, Value,
5};
6use runmat_macros::runtime_builtin;
7use runmat_thread_local::runmat_thread_local;
8use std::cell::RefCell;
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11
12use crate::builtins::common::path_search::{file_candidates, path_is_file};
13use crate::console::{record_console_line, ConsoleStream};
14use crate::{BuiltinResult, RuntimeError};
15
16const DBSTACK_OUTPUTS: [BuiltinParamDescriptor; 2] = [
17    BuiltinParamDescriptor {
18        name: "ST",
19        ty: BuiltinParamType::Any,
20        arity: BuiltinParamArity::Required,
21        default: None,
22        description: "Call-stack entries as a 1-by-N cell row of scalar structs.",
23    },
24    BuiltinParamDescriptor {
25        name: "I",
26        ty: BuiltinParamType::NumericScalar,
27        arity: BuiltinParamArity::Optional,
28        default: None,
29        description: "Current workspace index within the returned stack.",
30    },
31];
32
33const DBSTACK_STACK_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
34    name: "ST",
35    ty: BuiltinParamType::Any,
36    arity: BuiltinParamArity::Required,
37    default: None,
38    description: "Call-stack entries as a 1-by-N cell row of scalar structs.",
39}];
40
41const DBSTACK_INPUTS: [BuiltinParamDescriptor; 2] = [
42    BuiltinParamDescriptor {
43        name: "n",
44        ty: BuiltinParamType::IntegerScalar,
45        arity: BuiltinParamArity::Optional,
46        default: None,
47        description: "Number of leading stack entries to omit.",
48    },
49    BuiltinParamDescriptor {
50        name: "option",
51        ty: BuiltinParamType::StringScalar,
52        arity: BuiltinParamArity::Optional,
53        default: None,
54        description: "`-completenames` is accepted for MATLAB command compatibility.",
55    },
56];
57
58const DBSTACK_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
59    BuiltinSignatureDescriptor {
60        label: "dbstack",
61        inputs: &[],
62        outputs: &[],
63    },
64    BuiltinSignatureDescriptor {
65        label: "ST = dbstack(n, '-completenames')",
66        inputs: &DBSTACK_INPUTS,
67        outputs: &DBSTACK_STACK_OUTPUT,
68    },
69    BuiltinSignatureDescriptor {
70        label: "[ST,I] = dbstack(...)",
71        inputs: &DBSTACK_INPUTS,
72        outputs: &DBSTACK_OUTPUTS,
73    },
74];
75
76const TEXT_INPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
77    name: "name",
78    ty: BuiltinParamType::StringScalar,
79    arity: BuiltinParamArity::Optional,
80    default: None,
81    description: "Function, script, file, command, or option text.",
82}];
83
84const DBTYPE_INPUTS: [BuiltinParamDescriptor; 2] = [
85    BuiltinParamDescriptor {
86        name: "file",
87        ty: BuiltinParamType::StringScalar,
88        arity: BuiltinParamArity::Required,
89        default: None,
90        description: "MATLAB source file, path, or function name.",
91    },
92    BuiltinParamDescriptor {
93        name: "range",
94        ty: BuiltinParamType::StringScalar,
95        arity: BuiltinParamArity::Optional,
96        default: None,
97        description: "Optional line range such as `3:8`.",
98    },
99];
100
101const DBTYPE_FILE_INPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
102    name: "file",
103    ty: BuiltinParamType::StringScalar,
104    arity: BuiltinParamArity::Required,
105    default: None,
106    description: "MATLAB source file, path, or function name.",
107}];
108
109const DBTYPE_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
110    BuiltinSignatureDescriptor {
111        label: "dbtype file",
112        inputs: &DBTYPE_FILE_INPUT,
113        outputs: &[],
114    },
115    BuiltinSignatureDescriptor {
116        label: "dbtype file start:end",
117        inputs: &DBTYPE_INPUTS,
118        outputs: &[],
119    },
120];
121
122const SIMPLE_NO_OUTPUT_SIGNATURE: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
123    label: "keyboard",
124    inputs: &[],
125    outputs: &[],
126}];
127
128const MLOCK_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
129    label: "mlock",
130    inputs: &[],
131    outputs: &[],
132}];
133
134const MUNLOCK_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
135    BuiltinSignatureDescriptor {
136        label: "munlock",
137        inputs: &[],
138        outputs: &[],
139    },
140    BuiltinSignatureDescriptor {
141        label: "munlock(fun)",
142        inputs: &TEXT_INPUT,
143        outputs: &[],
144    },
145];
146
147const MISLOCKED_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
148    name: "tf",
149    ty: BuiltinParamType::Any,
150    arity: BuiltinParamArity::Required,
151    default: None,
152    description: "Logical scalar indicating whether the function or script is locked.",
153}];
154
155const MISLOCKED_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
156    BuiltinSignatureDescriptor {
157        label: "tf = mislocked",
158        inputs: &[],
159        outputs: &MISLOCKED_OUTPUT,
160    },
161    BuiltinSignatureDescriptor {
162        label: "tf = mislocked(fun)",
163        inputs: &TEXT_INPUT,
164        outputs: &MISLOCKED_OUTPUT,
165    },
166];
167
168const DBSTATUS_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
169    BuiltinSignatureDescriptor {
170        label: "dbstatus",
171        inputs: &TEXT_INPUT,
172        outputs: &[],
173    },
174    BuiltinSignatureDescriptor {
175        label: "b = dbstatus(file, '-completenames')",
176        inputs: &TEXT_INPUT,
177        outputs: &DBSTATUS_OUTPUT,
178    },
179];
180
181const DBSTATUS_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
182    name: "b",
183    ty: BuiltinParamType::Any,
184    arity: BuiltinParamArity::Required,
185    default: None,
186    description: "Breakpoint entries as a 1-by-N cell row; currently empty until execution breakpoints are implemented.",
187}];
188
189const DBCLEAR_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
190    label: "dbclear all | dbclear in file at location | dbclear if condition",
191    inputs: &TEXT_INPUT,
192    outputs: &[],
193}];
194
195const GETCALLINFO_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
196    label: "info = getcallinfo",
197    inputs: &[],
198    outputs: &GETCALLINFO_OUTPUT,
199}];
200
201const GETCALLINFO_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
202    name: "info",
203    ty: BuiltinParamType::Any,
204    arity: BuiltinParamArity::Required,
205    default: None,
206    description: "Scalar struct describing the current call context and stack.",
207}];
208
209pub const DEBUG_ERROR_TOO_MANY_INPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
210    code: "RM.DEBUG.TOO_MANY_INPUTS",
211    identifier: Some("RunMat:TooManyInputs"),
212    when: "A debugger compatibility helper receives more inputs than it supports.",
213    message: "debugger helper: too many input arguments",
214};
215
216pub const DEBUG_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
217    code: "RM.DEBUG.INVALID_INPUT",
218    identifier: Some("RunMat:InvalidInput"),
219    when: "An input is not a supported text, scalar, or option value.",
220    message: "debugger helper: invalid input",
221};
222
223pub const DEBUG_ERROR_NO_CURRENT_FILE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
224    code: "RM.DEBUG.NO_CURRENT_FILE",
225    identifier: Some("RunMat:NoCurrentFile"),
226    when: "`mlock`, `munlock`, or `mislocked` is called without a current function or script.",
227    message: "debugger helper: no current function or script is available",
228};
229
230pub const DEBUG_ERROR_FILE_NOT_FOUND: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
231    code: "RM.DEBUG.FILE_NOT_FOUND",
232    identifier: Some("RunMat:FileNotFound"),
233    when: "`dbtype` cannot resolve a MATLAB source file.",
234    message: "dbtype: file not found",
235};
236
237pub const DEBUG_ERROR_FILE_READ: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
238    code: "RM.DEBUG.FILE_READ",
239    identifier: Some("RunMat:FileReadFailed"),
240    when: "`dbtype` cannot read the resolved source file.",
241    message: "dbtype: failed to read file",
242};
243
244pub const DEBUG_ERRORS: [BuiltinErrorDescriptor; 5] = [
245    DEBUG_ERROR_TOO_MANY_INPUTS,
246    DEBUG_ERROR_INVALID_INPUT,
247    DEBUG_ERROR_NO_CURRENT_FILE,
248    DEBUG_ERROR_FILE_NOT_FOUND,
249    DEBUG_ERROR_FILE_READ,
250];
251
252pub const DBSTACK_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
253    signatures: &DBSTACK_SIGNATURES,
254    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
255    completion_policy: BuiltinCompletionPolicy::Public,
256    errors: &DEBUG_ERRORS,
257};
258
259pub const DBTYPE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
260    signatures: &DBTYPE_SIGNATURES,
261    output_mode: BuiltinOutputMode::Fixed,
262    completion_policy: BuiltinCompletionPolicy::Public,
263    errors: &DEBUG_ERRORS,
264};
265
266pub const KEYBOARD_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
267    signatures: &SIMPLE_NO_OUTPUT_SIGNATURE,
268    output_mode: BuiltinOutputMode::Fixed,
269    completion_policy: BuiltinCompletionPolicy::Public,
270    errors: &[],
271};
272
273pub const MLOCK_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
274    signatures: &MLOCK_SIGNATURES,
275    output_mode: BuiltinOutputMode::Fixed,
276    completion_policy: BuiltinCompletionPolicy::Public,
277    errors: &DEBUG_ERRORS,
278};
279
280pub const MUNLOCK_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
281    signatures: &MUNLOCK_SIGNATURES,
282    output_mode: BuiltinOutputMode::Fixed,
283    completion_policy: BuiltinCompletionPolicy::Public,
284    errors: &DEBUG_ERRORS,
285};
286
287pub const MISLOCKED_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
288    signatures: &MISLOCKED_SIGNATURES,
289    output_mode: BuiltinOutputMode::Fixed,
290    completion_policy: BuiltinCompletionPolicy::Public,
291    errors: &DEBUG_ERRORS,
292};
293
294pub const DBSTATUS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
295    signatures: &DBSTATUS_SIGNATURES,
296    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
297    completion_policy: BuiltinCompletionPolicy::Public,
298    errors: &DEBUG_ERRORS,
299};
300
301pub const DBCLEAR_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
302    signatures: &DBCLEAR_SIGNATURES,
303    output_mode: BuiltinOutputMode::Fixed,
304    completion_policy: BuiltinCompletionPolicy::Public,
305    errors: &DEBUG_ERRORS,
306};
307
308pub const GETCALLINFO_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
309    signatures: &GETCALLINFO_SIGNATURES,
310    output_mode: BuiltinOutputMode::Fixed,
311    completion_policy: BuiltinCompletionPolicy::Public,
312    errors: &DEBUG_ERRORS,
313};
314
315runmat_thread_local! {
316    static LOCKED_FUNCTIONS: RefCell<HashSet<String>> = RefCell::new(HashSet::new());
317}
318
319pub fn reset_lock_registry_for_tests() {
320    LOCKED_FUNCTIONS.with(|locks| locks.borrow_mut().clear());
321}
322
323fn debug_error(
324    builtin: &'static str,
325    descriptor: &'static BuiltinErrorDescriptor,
326    detail: impl AsRef<str>,
327) -> RuntimeError {
328    let detail = detail.as_ref();
329    let message = if detail.is_empty() {
330        descriptor.message.to_string()
331    } else {
332        format!("{}: {detail}", descriptor.message)
333    };
334    let mut builder = crate::build_runtime_error(message).with_builtin(builtin);
335    if let Some(identifier) = descriptor.identifier {
336        builder = builder.with_identifier(identifier);
337    }
338    builder.build()
339}
340
341fn empty_value() -> Value {
342    Value::Tensor(Tensor::new(Vec::new(), vec![0, 0]).expect("empty tensor"))
343}
344
345fn text_value(value: &Value) -> Option<String> {
346    match value {
347        Value::String(text) => Some(text.clone()),
348        Value::StringArray(array) if array.data.len() == 1 => Some(array.data[0].clone()),
349        Value::CharArray(array) if array.rows == 1 => Some(array.data.iter().collect()),
350        _ => None,
351    }
352}
353
354fn integer_value(value: &Value) -> Option<usize> {
355    match value {
356        Value::Num(n) if n.is_finite() && *n >= 0.0 && n.fract() == 0.0 => Some(*n as usize),
357        Value::Int(int) => usize::try_from(int.to_i64()).ok(),
358        _ => None,
359    }
360}
361
362fn stack_struct(frame: &crate::debug_context::DebugFrameInfo, index: usize) -> Value {
363    let mut st = StructValue::new();
364    st.insert("file", Value::String(frame.file.clone()));
365    st.insert("name", Value::String(frame.function.clone()));
366    st.insert("line", Value::Num(frame.line as f64));
367    st.insert("I", Value::Num(index as f64));
368    Value::Struct(st)
369}
370
371fn cell_row(values: Vec<Value>) -> BuiltinResult<Value> {
372    if values.is_empty() {
373        return Ok(Value::Cell(CellArray::new(Vec::new(), 0, 0).map_err(
374            |err| debug_error("debugger", &DEBUG_ERROR_INVALID_INPUT, &err),
375        )?));
376    }
377    let cols = values.len();
378    Ok(Value::Cell(CellArray::new(values, 1, cols).map_err(
379        |err| debug_error("debugger", &DEBUG_ERROR_INVALID_INPUT, &err),
380    )?))
381}
382
383fn stack_value(skip: usize) -> BuiltinResult<Value> {
384    let mut frames = crate::debug_context::current_frames();
385    if frames.is_empty() {
386        if let Some(source) = crate::source_context::current_source_info() {
387            frames.push(crate::debug_context::DebugFrameInfo {
388                function: source.name.to_string(),
389                file: source
390                    .fullpath_name
391                    .as_ref()
392                    .map(ToString::to_string)
393                    .unwrap_or_else(|| source.name.to_string()),
394                line: 0,
395            });
396        }
397    }
398    let entries = frames
399        .iter()
400        .skip(skip)
401        .enumerate()
402        .map(|(idx, frame)| stack_struct(frame, idx + 1))
403        .collect::<Vec<_>>();
404    cell_row(entries)
405}
406
407fn parse_dbstack_args(args: &[Value]) -> BuiltinResult<usize> {
408    let mut skip = 0usize;
409    for arg in args {
410        if let Some(text) = text_value(arg) {
411            if text.trim().eq_ignore_ascii_case("-completenames") {
412                continue;
413            }
414            if let Ok(n) = text.trim().parse::<usize>() {
415                skip = n;
416                continue;
417            }
418            return Err(debug_error("dbstack", &DEBUG_ERROR_INVALID_INPUT, text));
419        }
420        if let Some(n) = integer_value(arg) {
421            skip = n;
422            continue;
423        }
424        return Err(debug_error("dbstack", &DEBUG_ERROR_INVALID_INPUT, ""));
425    }
426    Ok(skip)
427}
428
429pub(crate) fn dispatch_dbstack(args: Vec<Value>) -> BuiltinResult<Value> {
430    let skip = parse_dbstack_args(&args)?;
431    let stack = stack_value(skip)?;
432    match crate::output_count::current_output_count() {
433        Some(0) => {
434            record_console_line(ConsoleStream::Stdout, format_stack_for_display(&stack));
435            Ok(empty_value())
436        }
437        Some(n) if n >= 2 => Ok(crate::output_count::output_list_with_padding(
438            n,
439            vec![stack, Value::Num(1.0)],
440        )),
441        _ => Ok(stack),
442    }
443}
444
445fn format_stack_for_display(stack: &Value) -> String {
446    let Value::Cell(cell) = stack else {
447        return String::new();
448    };
449    let lines = cell
450        .data
451        .iter()
452        .filter_map(|value| {
453            let Value::Struct(st) = value else {
454                return None;
455            };
456            let name = st
457                .fields
458                .get("name")
459                .and_then(text_value)
460                .unwrap_or_default();
461            let file = st
462                .fields
463                .get("file")
464                .and_then(text_value)
465                .unwrap_or_default();
466            let line = st
467                .fields
468                .get("line")
469                .and_then(|value| match value {
470                    Value::Num(n) => Some(*n as usize),
471                    _ => None,
472                })
473                .unwrap_or(0);
474            Some(if file.is_empty() {
475                format!("In {name} at line {line}")
476            } else {
477                format!("In {name} ({file}) at line {line}")
478            })
479        })
480        .collect::<Vec<_>>();
481    lines.join("\n")
482}
483
484fn normalize_lock_key(raw: &str) -> String {
485    let path = Path::new(raw.trim());
486    let name = path
487        .file_stem()
488        .and_then(|stem| stem.to_str())
489        .filter(|stem| !stem.is_empty())
490        .unwrap_or_else(|| raw.trim());
491    name.to_ascii_lowercase()
492}
493
494fn current_lock_key() -> BuiltinResult<String> {
495    if let Some(name) = crate::debug_context::current_function_name() {
496        if !name.is_empty() && name != "<main>" && name != "<anonymous>" {
497            return Ok(normalize_lock_key(&name));
498        }
499    }
500    crate::source_context::current_source_info()
501        .map(|source| normalize_lock_key(&source.name))
502        .filter(|key| !key.is_empty())
503        .ok_or_else(|| debug_error("mlock", &DEBUG_ERROR_NO_CURRENT_FILE, ""))
504}
505
506fn optional_lock_key(args: &[Value], builtin: &'static str) -> BuiltinResult<String> {
507    match args {
508        [] => current_lock_key(),
509        [value] => text_value(value)
510            .map(|text| normalize_lock_key(&text))
511            .filter(|key| !key.is_empty())
512            .ok_or_else(|| debug_error(builtin, &DEBUG_ERROR_INVALID_INPUT, "")),
513        _ => Err(debug_error(builtin, &DEBUG_ERROR_TOO_MANY_INPUTS, "")),
514    }
515}
516
517pub(crate) fn dispatch_mlock(args: Vec<Value>) -> BuiltinResult<Value> {
518    if !args.is_empty() {
519        return Err(debug_error("mlock", &DEBUG_ERROR_TOO_MANY_INPUTS, ""));
520    }
521    let key = current_lock_key()?;
522    LOCKED_FUNCTIONS.with(|locks| {
523        locks.borrow_mut().insert(key);
524    });
525    Ok(empty_value())
526}
527
528pub(crate) fn dispatch_munlock(args: Vec<Value>) -> BuiltinResult<Value> {
529    let key = optional_lock_key(&args, "munlock")?;
530    LOCKED_FUNCTIONS.with(|locks| {
531        locks.borrow_mut().remove(&key);
532    });
533    Ok(empty_value())
534}
535
536pub(crate) fn dispatch_mislocked(args: Vec<Value>) -> BuiltinResult<Value> {
537    let key = optional_lock_key(&args, "mislocked")?;
538    let locked = LOCKED_FUNCTIONS.with(|locks| locks.borrow().contains(&key));
539    Ok(Value::Bool(locked))
540}
541
542fn empty_breakpoint_list() -> BuiltinResult<Value> {
543    cell_row(Vec::new())
544}
545
546pub(crate) fn dispatch_dbstatus(args: Vec<Value>) -> BuiltinResult<Value> {
547    for arg in &args {
548        if text_value(arg).is_none() {
549            return Err(debug_error("dbstatus", &DEBUG_ERROR_INVALID_INPUT, ""));
550        }
551    }
552    let status = empty_breakpoint_list()?;
553    match crate::output_count::current_output_count() {
554        Some(0) => Ok(empty_value()),
555        _ => Ok(status),
556    }
557}
558
559pub(crate) fn dispatch_dbclear(args: Vec<Value>) -> BuiltinResult<Value> {
560    for arg in &args {
561        if text_value(arg).is_none() {
562            return Err(debug_error("dbclear", &DEBUG_ERROR_INVALID_INPUT, ""));
563        }
564    }
565    Ok(empty_value())
566}
567
568fn parse_line_range(value: Option<&Value>) -> BuiltinResult<Option<(usize, usize)>> {
569    let Some(value) = value else {
570        return Ok(None);
571    };
572    let text =
573        text_value(value).ok_or_else(|| debug_error("dbtype", &DEBUG_ERROR_INVALID_INPUT, ""))?;
574    let Some((start, end)) = text.split_once(':') else {
575        let line = text
576            .trim()
577            .parse::<usize>()
578            .map_err(|_| debug_error("dbtype", &DEBUG_ERROR_INVALID_INPUT, text.clone()))?;
579        return Ok(Some((line, line)));
580    };
581    let start = start
582        .trim()
583        .parse::<usize>()
584        .map_err(|_| debug_error("dbtype", &DEBUG_ERROR_INVALID_INPUT, text.clone()))?;
585    let end = end
586        .trim()
587        .parse::<usize>()
588        .map_err(|_| debug_error("dbtype", &DEBUG_ERROR_INVALID_INPUT, text.clone()))?;
589    if start == 0 || end == 0 || start > end {
590        return Err(debug_error("dbtype", &DEBUG_ERROR_INVALID_INPUT, text));
591    }
592    Ok(Some((start, end)))
593}
594
595async fn resolve_dbtype_path(name: &str) -> Result<Option<PathBuf>, String> {
596    for candidate in file_candidates(name, &[".m", ""], "dbtype")? {
597        if path_is_file(&candidate).await {
598            return Ok(Some(candidate));
599        }
600    }
601    Ok(None)
602}
603
604pub(crate) async fn dispatch_dbtype(args: Vec<Value>) -> BuiltinResult<Value> {
605    if args.is_empty() {
606        return Err(debug_error("dbtype", &DEBUG_ERROR_INVALID_INPUT, ""));
607    }
608    if args.len() > 2 {
609        return Err(debug_error("dbtype", &DEBUG_ERROR_TOO_MANY_INPUTS, ""));
610    }
611    let file = text_value(&args[0])
612        .ok_or_else(|| debug_error("dbtype", &DEBUG_ERROR_INVALID_INPUT, "file must be text"))?;
613    let range = parse_line_range(args.get(1))?;
614    let path = resolve_dbtype_path(&file)
615        .await
616        .map_err(|err| debug_error("dbtype", &DEBUG_ERROR_FILE_NOT_FOUND, err))?
617        .ok_or_else(|| debug_error("dbtype", &DEBUG_ERROR_FILE_NOT_FOUND, file.clone()))?;
618    let text = runmat_filesystem::read_to_string_async(&path)
619        .await
620        .map_err(|err| debug_error("dbtype", &DEBUG_ERROR_FILE_READ, err.to_string()))?;
621    let lines = text.lines().collect::<Vec<_>>();
622    let (start, end) = range.unwrap_or((1, lines.len()));
623    let rendered = lines
624        .iter()
625        .enumerate()
626        .filter_map(|(idx, line)| {
627            let line_number = idx + 1;
628            if line_number < start || line_number > end {
629                None
630            } else {
631                Some(format!("{line_number:>5} {line}"))
632            }
633        })
634        .collect::<Vec<_>>()
635        .join("\n");
636    record_console_line(ConsoleStream::Stdout, rendered);
637    Ok(empty_value())
638}
639
640pub(crate) fn dispatch_keyboard(args: Vec<Value>) -> BuiltinResult<Value> {
641    if !args.is_empty() {
642        return Err(debug_error("keyboard", &DEBUG_ERROR_TOO_MANY_INPUTS, ""));
643    }
644    Ok(empty_value())
645}
646
647pub(crate) fn dispatch_getcallinfo(args: Vec<Value>) -> BuiltinResult<Value> {
648    if !args.is_empty() {
649        return Err(debug_error("getcallinfo", &DEBUG_ERROR_TOO_MANY_INPUTS, ""));
650    }
651    let mut info = StructValue::new();
652    let current = crate::debug_context::current_frames()
653        .into_iter()
654        .next()
655        .unwrap_or_else(|| crate::debug_context::DebugFrameInfo {
656            function: String::new(),
657            file: String::new(),
658            line: 0,
659        });
660    info.insert("name", Value::String(current.function));
661    info.insert("file", Value::String(current.file));
662    info.insert("line", Value::Num(current.line as f64));
663    info.insert("stack", stack_value(0)?);
664    Ok(Value::Struct(info))
665}
666
667#[runtime_builtin(
668    name = "dbstack",
669    category = "introspection",
670    summary = "Return or display the active RunMat call stack.",
671    descriptor(self::DBSTACK_DESCRIPTOR),
672    builtin_path = "crate::builtins::introspection::debugging"
673)]
674fn dbstack_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
675    dispatch_dbstack(args)
676}
677
678#[runtime_builtin(
679    name = "dbtype",
680    category = "introspection",
681    summary = "Display MATLAB source text with line numbers.",
682    sink = true,
683    suppress_auto_output = true,
684    descriptor(self::DBTYPE_DESCRIPTOR),
685    builtin_path = "crate::builtins::introspection::debugging"
686)]
687async fn dbtype_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
688    dispatch_dbtype(args).await
689}
690
691#[runtime_builtin(
692    name = "keyboard",
693    category = "introspection",
694    summary = "Compatibility no-op for MATLAB keyboard debugging stops.",
695    sink = true,
696    suppress_auto_output = true,
697    descriptor(self::KEYBOARD_DESCRIPTOR),
698    builtin_path = "crate::builtins::introspection::debugging"
699)]
700fn keyboard_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
701    dispatch_keyboard(args)
702}
703
704#[runtime_builtin(
705    name = "mlock",
706    category = "introspection",
707    summary = "Mark the current function or script as locked in RunMat's compatibility registry.",
708    sink = true,
709    suppress_auto_output = true,
710    descriptor(self::MLOCK_DESCRIPTOR),
711    builtin_path = "crate::builtins::introspection::debugging"
712)]
713fn mlock_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
714    dispatch_mlock(args)
715}
716
717#[runtime_builtin(
718    name = "munlock",
719    category = "introspection",
720    summary = "Remove a function or script from RunMat's lock compatibility registry.",
721    sink = true,
722    suppress_auto_output = true,
723    descriptor(self::MUNLOCK_DESCRIPTOR),
724    builtin_path = "crate::builtins::introspection::debugging"
725)]
726fn munlock_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
727    dispatch_munlock(args)
728}
729
730#[runtime_builtin(
731    name = "mislocked",
732    category = "introspection",
733    summary = "Return whether a function or script is in RunMat's lock compatibility registry.",
734    descriptor(self::MISLOCKED_DESCRIPTOR),
735    builtin_path = "crate::builtins::introspection::debugging"
736)]
737fn mislocked_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
738    dispatch_mislocked(args)
739}
740
741#[runtime_builtin(
742    name = "dbstatus",
743    category = "introspection",
744    summary = "Return RunMat debugger breakpoint status.",
745    descriptor(self::DBSTATUS_DESCRIPTOR),
746    builtin_path = "crate::builtins::introspection::debugging"
747)]
748fn dbstatus_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
749    dispatch_dbstatus(args)
750}
751
752#[runtime_builtin(
753    name = "dbclear",
754    category = "introspection",
755    summary = "Clear RunMat debugger breakpoint status entries.",
756    sink = true,
757    suppress_auto_output = true,
758    descriptor(self::DBCLEAR_DESCRIPTOR),
759    builtin_path = "crate::builtins::introspection::debugging"
760)]
761fn dbclear_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
762    dispatch_dbclear(args)
763}
764
765#[runtime_builtin(
766    name = "getcallinfo",
767    category = "introspection",
768    summary = "Return deterministic information about the current RunMat call context.",
769    descriptor(self::GETCALLINFO_DESCRIPTOR),
770    builtin_path = "crate::builtins::introspection::debugging"
771)]
772fn getcallinfo_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
773    dispatch_getcallinfo(args)
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use runmat_hir::SourceId;
780
781    fn cell_len(value: &Value) -> usize {
782        let Value::Cell(cell) = value else {
783            panic!("expected cell row, got {value:?}");
784        };
785        cell.data.len()
786    }
787
788    #[test]
789    fn dbstack_returns_source_backed_stack_cell() {
790        let source_id = SourceId(31);
791        let _catalog = crate::source_context::replace_source_catalog_with_fullpaths(vec![(
792            source_id,
793            "worker.m".to_string(),
794            Some("/tmp/worker.m".to_string()),
795            "function worker\nx = dbstack;\nend\n".to_string(),
796        )]);
797        let _guard = crate::debug_context::push_frame("worker", Some(source_id), Some((16, 25)));
798        let value = dispatch_dbstack(Vec::new()).expect("dbstack");
799        let Value::Cell(cell) = value else {
800            panic!("expected cell row");
801        };
802        assert_eq!(cell.data.len(), 1);
803        let Value::Struct(st) = &cell.data[0] else {
804            panic!("expected stack struct");
805        };
806        assert_eq!(st.fields.get("name"), Some(&Value::String("worker".into())));
807        assert_eq!(
808            st.fields.get("file"),
809            Some(&Value::String("/tmp/worker.m".into()))
810        );
811    }
812
813    #[test]
814    fn dbstack_skips_leading_entries() {
815        let _outer = crate::debug_context::push_frame("outer", None, None);
816        let _inner = crate::debug_context::push_frame("inner", None, None);
817        let value = dispatch_dbstack(vec![Value::Num(1.0)]).expect("dbstack");
818        assert_eq!(cell_len(&value), 1);
819        let Value::Cell(cell) = value else {
820            unreachable!();
821        };
822        let Value::Struct(st) = &cell.data[0] else {
823            panic!("expected stack struct");
824        };
825        assert_eq!(st.fields.get("name"), Some(&Value::String("outer".into())));
826    }
827
828    #[test]
829    fn mlock_mislocked_munlock_current_function() {
830        let _guard = crate::debug_context::push_frame("locked_fn", None, None);
831        dispatch_mlock(Vec::new()).expect("mlock");
832        assert_eq!(
833            dispatch_mislocked(Vec::new()).expect("mislocked"),
834            Value::Bool(true)
835        );
836        dispatch_munlock(vec![Value::String("locked_fn".into())]).expect("munlock");
837        assert_eq!(
838            dispatch_mislocked(vec![Value::String("locked_fn".into())]).expect("mislocked"),
839            Value::Bool(false)
840        );
841    }
842
843    #[test]
844    fn dbstatus_returns_empty_breakpoint_cell() {
845        let value = dispatch_dbstatus(Vec::new()).expect("dbstatus");
846        assert_eq!(cell_len(&value), 0);
847    }
848
849    #[test]
850    fn dbtype_reads_source_file_with_line_range() {
851        let dir = tempfile::tempdir().expect("tempdir");
852        let path = dir.path().join("demo_dbtype.m");
853        std::fs::write(&path, "a = 1;\nb = 2;\nc = 3;\n").expect("write source");
854        let result = futures::executor::block_on(dispatch_dbtype(vec![
855            Value::String(path.to_string_lossy().to_string()),
856            Value::String("2:3".to_string()),
857        ]))
858        .expect("dbtype");
859        assert!(matches!(result, Value::Tensor(t) if t.data.is_empty()));
860    }
861
862    #[test]
863    fn getcallinfo_reports_current_frame() {
864        let _guard = crate::debug_context::push_frame("callsite", None, None);
865        let value = dispatch_getcallinfo(Vec::new()).expect("getcallinfo");
866        let Value::Struct(st) = value else {
867            panic!("expected struct");
868        };
869        assert_eq!(
870            st.fields.get("name"),
871            Some(&Value::String("callsite".into()))
872        );
873        assert!(matches!(st.fields.get("stack"), Some(Value::Cell(_))));
874    }
875}