1mod ffi;
31
32use std::{
33 cmp::Ordering,
34 error::Error as StdError,
35 fmt,
36 marker::PhantomData,
37 ptr::{self, NonNull},
38 slice,
39 sync::Arc,
40 time::Duration,
41};
42
43const DEFAULT_CHECK_MODULE_NAME: &str = "main";
45const DEFAULT_DEFINITIONS_MODULE_NAME: &str = "@definitions";
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
50pub enum Severity {
51 Error,
53 Warning,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Diagnostic {
60 pub line: u32,
62 pub col: u32,
64 pub end_line: u32,
66 pub end_col: u32,
68 pub severity: Severity,
70 pub message: String,
72}
73
74#[derive(Debug, Clone, Default, PartialEq, Eq)]
76pub struct CheckResult {
77 pub diagnostics: Vec<Diagnostic>,
79 pub timed_out: bool,
81 pub cancelled: bool,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct EntrypointParam {
88 pub name: String,
90 pub annotation: String,
92 pub optional: bool,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Default)]
98pub struct EntrypointSchema {
99 pub params: Vec<EntrypointParam>,
101}
102
103impl CheckResult {
104 pub fn is_ok(&self) -> bool {
106 !self
107 .diagnostics
108 .iter()
109 .any(|diagnostic| diagnostic.severity == Severity::Error)
110 }
111
112 pub fn errors(&self) -> Vec<&Diagnostic> {
114 self.diagnostics_with_severity(Severity::Error)
115 }
116
117 pub fn warnings(&self) -> Vec<&Diagnostic> {
119 self.diagnostics_with_severity(Severity::Warning)
120 }
121
122 fn diagnostics_with_severity(&self, severity: Severity) -> Vec<&Diagnostic> {
124 self.diagnostics
125 .iter()
126 .filter(|diagnostic| diagnostic.severity == severity)
127 .collect()
128 }
129
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct CheckerPolicy {
135 pub strict_mode: bool,
137 pub solver: &'static str,
139 pub exposes_batch_queue: bool,
141}
142
143pub const fn checker_policy() -> CheckerPolicy {
145 CheckerPolicy {
146 strict_mode: true,
147 solver: "new",
148 exposes_batch_queue: false,
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum Error {
155 CreateCheckerFailed,
157 CreateCancellationTokenFailed,
159 Definitions(String),
161 EntrypointSchema(String),
163 InputTooLarge {
165 kind: &'static str,
167 len: usize,
169 },
170}
171
172impl fmt::Display for Error {
173 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
174 match self {
175 Self::CreateCheckerFailed => formatter.write_str("failed to create Luau checker"),
176 Self::CreateCancellationTokenFailed => {
177 formatter.write_str("failed to create Luau cancellation token")
178 }
179 Self::Definitions(message) => {
180 write!(formatter, "failed to load Luau definitions: {message}")
181 }
182 Self::EntrypointSchema(message) => {
183 write!(
184 formatter,
185 "failed to extract Luau entrypoint schema: {message}"
186 )
187 }
188 Self::InputTooLarge { kind, len } => {
189 write!(
190 formatter,
191 "{kind} input is too large for checker FFI boundary ({len} bytes)"
192 )
193 }
194 }
195 }
196}
197
198impl StdError for Error {}
199
200#[derive(Debug, Clone)]
202pub struct CheckerOptions {
203 pub default_timeout: Option<Duration>,
205 pub default_module_name: String,
207 pub default_definitions_module_name: String,
209}
210
211impl Default for CheckerOptions {
212 fn default() -> Self {
213 Self {
214 default_timeout: None,
215 default_module_name: DEFAULT_CHECK_MODULE_NAME.to_owned(),
216 default_definitions_module_name: DEFAULT_DEFINITIONS_MODULE_NAME.to_owned(),
217 }
218 }
219}
220
221#[derive(Debug, Clone, Copy, Default)]
223pub struct CheckOptions<'a> {
224 pub timeout: Option<Duration>,
226 pub module_name: Option<&'a str>,
228 pub cancellation_token: Option<&'a CancellationToken>,
230}
231
232#[derive(Clone, Debug)]
237pub struct CancellationToken {
238 inner: Arc<CancellationTokenInner>,
240}
241
242#[derive(Debug)]
244struct CancellationTokenInner {
245 raw: NonNull<ffi::LuauCancellationToken>,
247}
248
249unsafe impl Send for CancellationTokenInner {}
251unsafe impl Sync for CancellationTokenInner {}
253
254impl Drop for CancellationTokenInner {
255 fn drop(&mut self) {
256 unsafe { ffi::luau_cancellation_token_free(self.raw.as_ptr()) };
258 }
259}
260
261impl CancellationToken {
262 pub fn new() -> Result<Self, Error> {
264 let raw = NonNull::new(unsafe { ffi::luau_cancellation_token_new() })
266 .ok_or(Error::CreateCancellationTokenFailed)?;
267 Ok(Self {
268 inner: Arc::new(CancellationTokenInner { raw }),
269 })
270 }
271
272 pub fn cancel(&self) {
274 unsafe { ffi::luau_cancellation_token_cancel(self.inner.raw.as_ptr()) };
276 }
277
278 pub fn reset(&self) {
280 unsafe { ffi::luau_cancellation_token_reset(self.inner.raw.as_ptr()) };
282 }
283
284 fn raw(&self) -> *mut ffi::LuauCancellationToken {
286 self.inner.raw.as_ptr()
287 }
288}
289
290pub struct Checker {
297 inner: NonNull<ffi::LuauChecker>,
299 options: CheckerOptions,
301}
302
303unsafe impl Send for Checker {}
305
306impl Checker {
307 pub fn new() -> Result<Self, Error> {
309 Self::with_options(CheckerOptions::default())
310 }
311
312 pub fn with_options(options: CheckerOptions) -> Result<Self, Error> {
314 let inner =
316 NonNull::new(unsafe { ffi::luau_checker_new() }).ok_or(Error::CreateCheckerFailed)?;
317 Ok(Self { inner, options })
318 }
319
320 pub fn options(&self) -> &CheckerOptions {
322 &self.options
323 }
324
325 pub fn add_definitions(&mut self, defs: &str) -> Result<(), Error> {
327 let module_name = self.options.default_definitions_module_name.clone();
328 self.add_definitions_with_name(defs, &module_name)
329 }
330
331 pub fn add_definitions_with_name(
333 &mut self,
334 defs: &str,
335 module_name: &str,
336 ) -> Result<(), Error> {
337 let defs = FfiStr::new(defs, "definitions")?;
338 let module_name = FfiStr::new(module_name, "definition module name")?;
339
340 let raw = RawStringGuard::new(unsafe {
342 ffi::luau_checker_add_definitions(
343 self.inner.as_ptr(),
344 defs.ptr(),
345 defs.len(),
346 module_name.ptr(),
347 module_name.len(),
348 )
349 });
350
351 match raw.message() {
352 Some(message) => Err(Error::Definitions(message)),
353 None => Ok(()),
354 }
355 }
356
357 pub fn check(&mut self, source: &str) -> Result<CheckResult, Error> {
359 self.check_with_options(source, CheckOptions::default())
360 }
361
362 pub fn check_with_options(
364 &mut self,
365 source: &str,
366 options: CheckOptions<'_>,
367 ) -> Result<CheckResult, Error> {
368 let source = FfiStr::new(source, "source")?;
369
370 let module_name = options
371 .module_name
372 .unwrap_or(self.options.default_module_name.as_str());
373 let module_name = FfiStr::new(module_name, "module name")?;
374
375 let timeout = options.timeout.or(self.options.default_timeout);
376 let raw_options = ffi::LuauCheckOptions {
377 module_name: module_name.ptr(),
378 module_name_len: module_name.len(),
379 has_timeout: u32::from(timeout.is_some()),
380 timeout_seconds: timeout.map_or(0.0, |duration| duration.as_secs_f64()),
381 cancellation_token: options
382 .cancellation_token
383 .map_or(ptr::null_mut(), CancellationToken::raw),
384 };
385
386 let raw = unsafe {
388 ffi::luau_checker_check(
389 self.inner.as_ptr(),
390 source.ptr(),
391 source.len(),
392 &raw_options,
393 )
394 };
395 let raw = RawCheckResultGuard::new(raw);
396
397 let mut diagnostics = collect_diagnostics(raw.as_ref());
398
399 diagnostics.sort_by(diagnostic_sort_key);
400 Ok(CheckResult {
401 diagnostics,
402 timed_out: raw.as_ref().timed_out != 0,
403 cancelled: raw.as_ref().cancelled != 0,
404 })
405 }
406}
407
408pub fn extract_entrypoint_schema(source: &str) -> Result<EntrypointSchema, Error> {
411 let source = FfiStr::new(source, "source")?;
412
413 let raw = unsafe { ffi::luau_extract_entrypoint_schema(source.ptr(), source.len()) };
415 let raw = RawEntrypointSchemaGuard::new(raw);
416
417 if raw.as_ref().error_len != 0 {
418 return Err(Error::EntrypointSchema(string_from_raw(
419 raw.as_ref().error,
420 raw.as_ref().error_len,
421 )));
422 }
423
424 Ok(EntrypointSchema {
425 params: collect_entrypoint_params(raw.as_ref()),
426 })
427}
428
429impl Drop for Checker {
430 fn drop(&mut self) {
431 unsafe { ffi::luau_checker_free(self.inner.as_ptr()) };
433 }
434}
435
436#[derive(Clone, Copy)]
438struct FfiStr<'a> {
439 ptr: *const u8,
441 len: u32,
443 _marker: PhantomData<&'a str>,
445}
446
447impl<'a> FfiStr<'a> {
448 fn new(value: &'a str, kind: &'static str) -> Result<Self, Error> {
450 let len = u32::try_from(value.len()).map_err(|_| Error::InputTooLarge {
451 kind,
452 len: value.len(),
453 })?;
454
455 Ok(Self {
456 ptr: if len == 0 {
457 ptr::null()
458 } else {
459 value.as_ptr()
460 },
461 len,
462 _marker: PhantomData,
463 })
464 }
465
466 fn ptr(self) -> *const u8 {
468 self.ptr
469 }
470
471 fn len(self) -> u32 {
473 self.len
474 }
475}
476
477struct RawCheckResultGuard {
479 raw: ffi::LuauCheckResult,
481}
482
483impl RawCheckResultGuard {
484 fn new(raw: ffi::LuauCheckResult) -> Self {
486 Self { raw }
487 }
488
489 fn as_ref(&self) -> &ffi::LuauCheckResult {
491 &self.raw
492 }
493}
494
495impl Drop for RawCheckResultGuard {
496 fn drop(&mut self) {
497 unsafe { ffi::luau_check_result_free(self.raw) };
499 }
500}
501
502struct RawStringGuard {
504 raw: ffi::LuauString,
506}
507
508impl RawStringGuard {
509 fn new(raw: ffi::LuauString) -> Self {
511 Self { raw }
512 }
513
514 fn message(&self) -> Option<String> {
516 if self.raw.len == 0 {
517 None
518 } else {
519 Some(string_from_raw(self.raw.data, self.raw.len))
520 }
521 }
522}
523
524impl Drop for RawStringGuard {
525 fn drop(&mut self) {
526 unsafe { ffi::luau_string_free(self.raw) };
528 }
529}
530
531struct RawEntrypointSchemaGuard {
533 raw: ffi::LuauEntrypointSchemaResult,
535}
536
537impl RawEntrypointSchemaGuard {
538 fn new(raw: ffi::LuauEntrypointSchemaResult) -> Self {
540 Self { raw }
541 }
542
543 fn as_ref(&self) -> &ffi::LuauEntrypointSchemaResult {
545 &self.raw
546 }
547}
548
549impl Drop for RawEntrypointSchemaGuard {
550 fn drop(&mut self) {
551 unsafe { ffi::luau_entrypoint_schema_result_free(self.raw) };
553 }
554}
555
556fn string_from_raw(ptr: *const u8, len: u32) -> String {
558 if ptr.is_null() || len == 0 {
559 return String::new();
560 }
561
562 let bytes = unsafe { slice::from_raw_parts(ptr, len as usize) };
564 String::from_utf8_lossy(bytes).into_owned()
565}
566
567impl Severity {
568 fn from_ffi(code: u32) -> Self {
570 match code {
571 0 => Self::Error,
572 _ => Self::Warning,
573 }
574 }
575}
576
577fn collect_diagnostics(raw: &ffi::LuauCheckResult) -> Vec<Diagnostic> {
579 unsafe { raw_slice(raw.diagnostics, raw.diagnostic_count) }
581 .iter()
582 .map(|diagnostic| Diagnostic {
583 line: diagnostic.line,
584 col: diagnostic.col,
585 end_line: diagnostic.end_line,
586 end_col: diagnostic.end_col,
587 severity: Severity::from_ffi(diagnostic.severity),
588 message: string_from_raw(diagnostic.message, diagnostic.message_len),
589 })
590 .collect()
591}
592
593fn collect_entrypoint_params(raw: &ffi::LuauEntrypointSchemaResult) -> Vec<EntrypointParam> {
595 unsafe { raw_slice(raw.params, raw.param_count) }
597 .iter()
598 .map(|param| EntrypointParam {
599 name: string_from_raw(param.name, param.name_len),
600 annotation: string_from_raw(param.annotation, param.annotation_len),
601 optional: param.optional != 0,
602 })
603 .collect()
604}
605
606unsafe fn raw_slice<'a, T>(ptr: *const T, len: u32) -> &'a [T] {
608 if len == 0 {
609 &[]
610 } else {
611 debug_assert!(!ptr.is_null(), "non-empty shim slice must not be null");
612 unsafe { slice::from_raw_parts(ptr, len as usize) }
614 }
615}
616
617fn diagnostic_sort_key(left: &Diagnostic, right: &Diagnostic) -> Ordering {
619 left.line
620 .cmp(&right.line)
621 .then(left.col.cmp(&right.col))
622 .then(left.severity.cmp(&right.severity))
623 .then(left.message.cmp(&right.message))
624}
625
626#[cfg(test)]
628mod tests {
629 use super::{
630 CheckResult, CheckerOptions, Diagnostic, Severity, checker_policy,
631 extract_entrypoint_schema,
632 };
633
634 #[test]
636 fn check_result_ok_with_warnings() {
637 let result = CheckResult {
638 diagnostics: vec![Diagnostic {
639 line: 0,
640 col: 0,
641 end_line: 0,
642 end_col: 1,
643 severity: Severity::Warning,
644 message: "unused local".to_owned(),
645 }],
646 timed_out: false,
647 cancelled: false,
648 };
649
650 assert!(result.is_ok());
651 assert_eq!(1, result.warnings().len());
652 assert_eq!(0, result.errors().len());
653 }
654
655 #[test]
657 fn check_result_not_ok_with_error() {
658 let result = CheckResult {
659 diagnostics: vec![Diagnostic {
660 line: 1,
661 col: 1,
662 end_line: 1,
663 end_col: 5,
664 severity: Severity::Error,
665 message: "type mismatch".to_owned(),
666 }],
667 timed_out: false,
668 cancelled: false,
669 };
670
671 assert!(!result.is_ok());
672 assert_eq!(0, result.warnings().len());
673 assert_eq!(1, result.errors().len());
674 }
675
676 #[test]
678 fn policy_is_strict_new_solver_and_queue_free() {
679 let policy = checker_policy();
680 assert!(policy.strict_mode);
681 assert_eq!("new", policy.solver);
682 assert!(!policy.exposes_batch_queue);
683 }
684
685 #[test]
687 fn checker_options_defaults_are_stable() {
688 let options = CheckerOptions::default();
689 assert_eq!("main", options.default_module_name);
690 assert_eq!("@definitions", options.default_definitions_module_name);
691 assert!(options.default_timeout.is_none());
692 }
693
694 #[test]
696 fn extract_entrypoint_schema_reads_params() {
697 let schema = extract_entrypoint_schema(
698 r#"
699return function(target: Node, count: number?, payload: JsonValue)
700 return nil
701end
702"#,
703 )
704 .expect("schema");
705 assert_eq!(3, schema.params.len());
706 assert_eq!("target", schema.params[0].name);
707 assert_eq!("Node", schema.params[0].annotation);
708 assert!(!schema.params[0].optional);
709 assert_eq!("count", schema.params[1].name);
710 assert_eq!("number?", schema.params[1].annotation);
711 assert!(schema.params[1].optional);
712 assert_eq!("payload", schema.params[2].name);
713 assert_eq!("JsonValue", schema.params[2].annotation);
714 assert!(!schema.params[2].optional);
715 }
716
717 #[test]
719 fn extract_entrypoint_schema_rejects_indirect_return() {
720 let error = extract_entrypoint_schema(
721 r#"
722local main = function(target: Node)
723 return nil
724end
725return main
726"#,
727 )
728 .expect_err("schema should fail");
729 assert!(
730 error
731 .to_string()
732 .contains("script must use a direct `return function(...) ... end` entrypoint"),
733 "{error}"
734 );
735 }
736}