Skip to main content

nwnrs_nwscript/
driver.rs

1use std::{
2    collections::BTreeSet,
3    error::Error,
4    fmt, fs, io,
5    path::{Path, PathBuf},
6};
7
8use nwnrs_types::resman::prelude::{ResType, get_res_ext};
9
10use crate::{
11    CompileArtifacts, CompilerSession, CompilerSessionError, CompilerSessionOptions,
12    NW_SCRIPT_SOURCE_RES_TYPE, ScriptResolver, SourceError, session::PreparedScript,
13};
14
15/// Errors returned while resolving or writing through a callback-driven
16/// compiler host.
17#[derive(#[automatically_derived]
impl ::core::fmt::Debug for CompilerHostError {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field1_finish(f,
            "CompilerHostError", "message", &&self.message)
    }
}Debug, #[automatically_derived]
impl ::core::clone::Clone for CompilerHostError {
    #[inline]
    fn clone(&self) -> CompilerHostError {
        CompilerHostError {
            message: ::core::clone::Clone::clone(&self.message),
        }
    }
}Clone, #[automatically_derived]
impl ::core::cmp::PartialEq for CompilerHostError {
    #[inline]
    fn eq(&self, other: &CompilerHostError) -> bool {
        self.message == other.message
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for CompilerHostError {
    #[inline]
    #[doc(hidden)]
    #[coverage(off)]
    fn assert_fields_are_eq(&self) {
        let _: ::core::cmp::AssertParamIsEq<String>;
    }
}Eq)]
18pub struct CompilerHostError {
19    /// Human-readable error message.
20    pub message: String,
21}
22
23impl CompilerHostError {
24    /// Creates one host error from arbitrary text.
25    #[must_use]
26    pub fn new(message: impl Into<String>) -> Self {
27        Self {
28            message: message.into(),
29        }
30    }
31}
32
33impl fmt::Display for CompilerHostError {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        f.write_str(&self.message)
36    }
37}
38
39impl Error for CompilerHostError {}
40
41impl From<io::Error> for CompilerHostError {
42    fn from(value: io::Error) -> Self {
43        Self::new(value.to_string())
44    }
45}
46
47/// One callback-driven host for loading NWScript source and receiving compiler
48/// outputs.
49pub trait CompilerHost {
50    /// Resolves one logical script name for the requested resource type.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`SourceError`] if the underlying lookup fails.
55    fn resolve_script_bytes(
56        &self,
57        script_name: &str,
58        res_type: ResType,
59    ) -> Result<Option<Vec<u8>>, SourceError>;
60
61    /// Receives one emitted compiler artifact.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`CompilerHostError`] if the host cannot persist or accept the
66    /// output.
67    fn write_file(
68        &mut self,
69        file_name: &str,
70        res_type: ResType,
71        data: &[u8],
72        binary: bool,
73    ) -> Result<(), CompilerHostError>;
74
75    /// Receives one Graphviz DOT file when graphviz output is requested.
76    ///
77    /// # Errors
78    ///
79    /// Returns [`CompilerHostError`] if the host cannot persist or accept the
80    /// output.
81    fn write_graphviz(&mut self, _file_name: &str, _dot: &str) -> Result<(), CompilerHostError> {
82        Ok(())
83    }
84}
85
86/// Options controlling one callback-driven compiler invocation.
87#[derive(#[automatically_derived]
impl ::core::fmt::Debug for CompilerDriverOptions {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        let names: &'static _ =
            &["session", "source_res_type", "binary_res_type",
                        "debug_res_type", "output_alias", "emit_graphviz",
                        "graphviz_alias", "skip_missing_entrypoint"];
        let values: &[&dyn ::core::fmt::Debug] =
            &[&self.session, &self.source_res_type, &self.binary_res_type,
                        &self.debug_res_type, &self.output_alias,
                        &self.emit_graphviz, &self.graphviz_alias,
                        &&self.skip_missing_entrypoint];
        ::core::fmt::Formatter::debug_struct_fields_finish(f,
            "CompilerDriverOptions", names, values)
    }
}Debug, #[automatically_derived]
impl ::core::clone::Clone for CompilerDriverOptions {
    #[inline]
    fn clone(&self) -> CompilerDriverOptions {
        CompilerDriverOptions {
            session: ::core::clone::Clone::clone(&self.session),
            source_res_type: ::core::clone::Clone::clone(&self.source_res_type),
            binary_res_type: ::core::clone::Clone::clone(&self.binary_res_type),
            debug_res_type: ::core::clone::Clone::clone(&self.debug_res_type),
            output_alias: ::core::clone::Clone::clone(&self.output_alias),
            emit_graphviz: ::core::clone::Clone::clone(&self.emit_graphviz),
            graphviz_alias: ::core::clone::Clone::clone(&self.graphviz_alias),
            skip_missing_entrypoint: ::core::clone::Clone::clone(&self.skip_missing_entrypoint),
        }
    }
}Clone, #[automatically_derived]
impl ::core::cmp::PartialEq for CompilerDriverOptions {
    #[inline]
    fn eq(&self, other: &CompilerDriverOptions) -> bool {
        self.emit_graphviz == other.emit_graphviz &&
                                    self.skip_missing_entrypoint ==
                                        other.skip_missing_entrypoint &&
                                self.session == other.session &&
                            self.source_res_type == other.source_res_type &&
                        self.binary_res_type == other.binary_res_type &&
                    self.debug_res_type == other.debug_res_type &&
                self.output_alias == other.output_alias &&
            self.graphviz_alias == other.graphviz_alias
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for CompilerDriverOptions {
    #[inline]
    #[doc(hidden)]
    #[coverage(off)]
    fn assert_fields_are_eq(&self) {
        let _: ::core::cmp::AssertParamIsEq<CompilerSessionOptions>;
        let _: ::core::cmp::AssertParamIsEq<ResType>;
        let _: ::core::cmp::AssertParamIsEq<String>;
        let _: ::core::cmp::AssertParamIsEq<bool>;
        let _: ::core::cmp::AssertParamIsEq<Option<String>>;
    }
}Eq)]
88pub struct CompilerDriverOptions {
89    /// Reusable session settings for parsing and code generation.
90    pub session:                 CompilerSessionOptions,
91    /// Resource type requested for source resolution.
92    pub source_res_type:         ResType,
93    /// Resource type used when emitting compiled bytecode.
94    pub binary_res_type:         ResType,
95    /// Resource type used when emitting debug output.
96    pub debug_res_type:          ResType,
97    /// Base output name used for emitted artifacts.
98    pub output_alias:            String,
99    /// Whether to emit Graphviz DOT for the parsed AST.
100    pub emit_graphviz:           bool,
101    /// Optional output name for the emitted Graphviz DOT file.
102    pub graphviz_alias:          Option<String>,
103    /// Whether scripts without `main()` or `StartingConditional()` should be
104    /// skipped.
105    pub skip_missing_entrypoint: bool,
106}
107
108impl Default for CompilerDriverOptions {
109    fn default() -> Self {
110        Self {
111            session:                 CompilerSessionOptions::default(),
112            source_res_type:         NW_SCRIPT_SOURCE_RES_TYPE,
113            binary_res_type:         ResType(2010),
114            debug_res_type:          ResType(2064),
115            output_alias:            "scriptout".to_string(),
116            emit_graphviz:           false,
117            graphviz_alias:          None,
118            skip_missing_entrypoint: false,
119        }
120    }
121}
122
123/// Result of one callback-driven compile request.
124#[derive(#[automatically_derived]
impl ::core::fmt::Debug for CompileFileOutcome {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        match self {
            CompileFileOutcome::Compiled(__self_0) =>
                ::core::fmt::Formatter::debug_tuple_field1_finish(f,
                    "Compiled", &__self_0),
            CompileFileOutcome::SkippedNoEntrypoint =>
                ::core::fmt::Formatter::write_str(f, "SkippedNoEntrypoint"),
        }
    }
}Debug, #[automatically_derived]
impl ::core::clone::Clone for CompileFileOutcome {
    #[inline]
    fn clone(&self) -> CompileFileOutcome {
        match self {
            CompileFileOutcome::Compiled(__self_0) =>
                CompileFileOutcome::Compiled(::core::clone::Clone::clone(__self_0)),
            CompileFileOutcome::SkippedNoEntrypoint =>
                CompileFileOutcome::SkippedNoEntrypoint,
        }
    }
}Clone, #[automatically_derived]
impl ::core::cmp::PartialEq for CompileFileOutcome {
    #[inline]
    fn eq(&self, other: &CompileFileOutcome) -> bool {
        let __self_discr = ::core::intrinsics::discriminant_value(self);
        let __arg1_discr = ::core::intrinsics::discriminant_value(other);
        __self_discr == __arg1_discr &&
            match (self, other) {
                (CompileFileOutcome::Compiled(__self_0),
                    CompileFileOutcome::Compiled(__arg1_0)) =>
                    __self_0 == __arg1_0,
                _ => true,
            }
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for CompileFileOutcome {
    #[inline]
    #[doc(hidden)]
    #[coverage(off)]
    fn assert_fields_are_eq(&self) {
        let _: ::core::cmp::AssertParamIsEq<CompileArtifacts>;
    }
}Eq)]
125pub enum CompileFileOutcome {
126    /// The script compiled successfully and artifacts were written through the
127    /// host.
128    Compiled(CompileArtifacts),
129    /// The script was skipped because it has no executable entrypoint.
130    SkippedNoEntrypoint,
131}
132
133/// Errors returned while executing one callback-driven compile request.
134#[derive(#[automatically_derived]
impl ::core::fmt::Debug for CompilerDriverError {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        match self {
            CompilerDriverError::Session(__self_0) =>
                ::core::fmt::Formatter::debug_tuple_field1_finish(f,
                    "Session", &__self_0),
            CompilerDriverError::Host(__self_0) =>
                ::core::fmt::Formatter::debug_tuple_field1_finish(f, "Host",
                    &__self_0),
        }
    }
}Debug)]
135pub enum CompilerDriverError {
136    /// Session loading, parsing, or code generation failed.
137    Session(CompilerSessionError),
138    /// The host failed while persisting output.
139    Host(CompilerHostError),
140}
141
142impl fmt::Display for CompilerDriverError {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        match self {
145            Self::Session(error) => error.fmt(f),
146            Self::Host(error) => error.fmt(f),
147        }
148    }
149}
150
151impl Error for CompilerDriverError {}
152
153impl From<CompilerSessionError> for CompilerDriverError {
154    fn from(value: CompilerSessionError) -> Self {
155        Self::Session(value)
156    }
157}
158
159impl From<CompilerHostError> for CompilerDriverError {
160    fn from(value: CompilerHostError) -> Self {
161        Self::Host(value)
162    }
163}
164
165struct HostResolver<'a, H> {
166    host: &'a H,
167}
168
169impl<H: CompilerHost> ScriptResolver for HostResolver<'_, H> {
170    fn resolve_script_bytes(
171        &self,
172        script_name: &str,
173        res_type: ResType,
174    ) -> Result<Option<Vec<u8>>, SourceError> {
175        self.host.resolve_script_bytes(script_name, res_type)
176    }
177}
178
179/// Compiles one logical script through a callback-driven host.
180///
181/// # Errors
182///
183/// Returns [`CompilerDriverError`] if source loading, parsing, code generation,
184/// or host output persistence fails.
185pub fn compile_file_with_host<H: CompilerHost>(
186    host: &mut H,
187    script_name: &str,
188    options: &CompilerDriverOptions,
189) -> Result<CompileFileOutcome, CompilerDriverError> {
190    let (prepared, artifacts, graphviz) = {
191        let resolver = HostResolver {
192            host: &*host
193        };
194        let mut session = CompilerSession::with_options(&resolver, options.session.clone());
195        let prepared = session.prepare_script_name(script_name)?;
196        if options.skip_missing_entrypoint && !prepared_has_entrypoint(&prepared) {
197            return Ok(CompileFileOutcome::SkippedNoEntrypoint);
198        }
199        let graphviz = if options.emit_graphviz {
200            Some(crate::render_script_graphviz(
201                &prepared.script,
202                Some(&prepared.bundle.source_map),
203            ))
204        } else {
205            None
206        };
207        let artifacts = session
208            .compile_prepared(&prepared)
209            .map_err(CompilerSessionError::from)
210            .map_err(CompilerDriverError::from)?;
211        (prepared, artifacts, graphviz)
212    };
213
214    host.write_file(
215        &options.output_alias,
216        options.binary_res_type,
217        &artifacts.ncs,
218        true,
219    )?;
220    if let Some(ndb) = artifacts.ndb.as_ref() {
221        host.write_file(&options.output_alias, options.debug_res_type, ndb, true)?;
222    }
223    if let Some(dot) = graphviz.as_deref() {
224        let graphviz_alias = options
225            .graphviz_alias
226            .as_deref()
227            .unwrap_or(&options.output_alias);
228        host.write_graphviz(graphviz_alias, dot)?;
229    }
230    let _ = prepared;
231    Ok(CompileFileOutcome::Compiled(artifacts))
232}
233
234fn prepared_has_entrypoint(prepared: &PreparedScript) -> bool {
235    prepared.script.items.iter().any(|item| match item {
236        crate::TopLevelItem::Function(function) => {
237            function.body.is_some()
238                && (function.name == "main" || function.name == "StartingConditional")
239        }
240        _ => false,
241    })
242}
243
244/// One filesystem-backed source resolver that searches one or more root
245/// directories.
246#[derive(#[automatically_derived]
impl ::core::fmt::Debug for FileSystemScriptResolver {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field1_finish(f,
            "FileSystemScriptResolver", "roots", &&self.roots)
    }
}Debug, #[automatically_derived]
impl ::core::clone::Clone for FileSystemScriptResolver {
    #[inline]
    fn clone(&self) -> FileSystemScriptResolver {
        FileSystemScriptResolver {
            roots: ::core::clone::Clone::clone(&self.roots),
        }
    }
}Clone, #[automatically_derived]
impl ::core::default::Default for FileSystemScriptResolver {
    #[inline]
    fn default() -> FileSystemScriptResolver {
        FileSystemScriptResolver {
            roots: ::core::default::Default::default(),
        }
    }
}Default, #[automatically_derived]
impl ::core::cmp::PartialEq for FileSystemScriptResolver {
    #[inline]
    fn eq(&self, other: &FileSystemScriptResolver) -> bool {
        self.roots == other.roots
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for FileSystemScriptResolver {
    #[inline]
    #[doc(hidden)]
    #[coverage(off)]
    fn assert_fields_are_eq(&self) {
        let _: ::core::cmp::AssertParamIsEq<Vec<PathBuf>>;
    }
}Eq)]
247pub struct FileSystemScriptResolver {
248    roots: Vec<PathBuf>,
249}
250
251impl FileSystemScriptResolver {
252    /// Creates one empty filesystem resolver.
253    #[must_use]
254    pub fn new() -> Self {
255        Self::default()
256    }
257
258    /// Creates one filesystem resolver with an initial root.
259    #[must_use]
260    pub fn with_root(root: impl Into<PathBuf>) -> Self {
261        let mut resolver = Self::new();
262        resolver.add_root(root);
263        resolver
264    }
265
266    /// Adds one search root used for relative script names.
267    pub fn add_root(&mut self, root: impl Into<PathBuf>) {
268        self.roots.push(root.into());
269    }
270
271    fn candidate_paths(&self, script_name: &str) -> Vec<PathBuf> {
272        let path = Path::new(script_name);
273        let mut names = ::alloc::boxed::box_assume_init_into_vec_unsafe(::alloc::intrinsics::write_box_via_move(::alloc::boxed::Box::new_uninit(),
        [PathBuf::from(script_name)]))vec![PathBuf::from(script_name)];
274        if path.extension().is_none() {
275            names.push(PathBuf::from(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{1}.{0}",
                get_res_ext(NW_SCRIPT_SOURCE_RES_TYPE), script_name))
    })format!(
276                "{script_name}.{}",
277                get_res_ext(NW_SCRIPT_SOURCE_RES_TYPE)
278            )));
279        }
280
281        let mut candidates = Vec::new();
282        for name in names {
283            if path.is_absolute() || name.is_absolute() {
284                candidates.push(name.clone());
285            } else {
286                candidates.push(name.clone());
287                for root in &self.roots {
288                    candidates.push(root.join(&name));
289                }
290            }
291        }
292        candidates
293    }
294}
295
296impl ScriptResolver for FileSystemScriptResolver {
297    fn resolve_script_bytes(
298        &self,
299        script_name: &str,
300        res_type: ResType,
301    ) -> Result<Option<Vec<u8>>, SourceError> {
302        if res_type != NW_SCRIPT_SOURCE_RES_TYPE {
303            return Ok(None);
304        }
305        for candidate in self.candidate_paths(script_name) {
306            if candidate.is_file() {
307                return fs::read(&candidate)
308                    .map(Some)
309                    .map_err(|error| SourceError::resolver(error.to_string()));
310            }
311        }
312        Ok(None)
313    }
314}
315
316/// One directory-backed compiler host that reads source files from filesystem
317/// roots and writes outputs back to disk.
318#[derive(#[automatically_derived]
impl ::core::fmt::Debug for DirectoryCompilerHost {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field5_finish(f,
            "DirectoryCompilerHost", "resolver", &self.resolver,
            "output_directory", &self.output_directory, "graphviz_directory",
            &self.graphviz_directory, "simulate", &self.simulate,
            "written_paths", &&self.written_paths)
    }
}Debug, #[automatically_derived]
impl ::core::clone::Clone for DirectoryCompilerHost {
    #[inline]
    fn clone(&self) -> DirectoryCompilerHost {
        DirectoryCompilerHost {
            resolver: ::core::clone::Clone::clone(&self.resolver),
            output_directory: ::core::clone::Clone::clone(&self.output_directory),
            graphviz_directory: ::core::clone::Clone::clone(&self.graphviz_directory),
            simulate: ::core::clone::Clone::clone(&self.simulate),
            written_paths: ::core::clone::Clone::clone(&self.written_paths),
        }
    }
}Clone)]
319pub struct DirectoryCompilerHost {
320    resolver:           FileSystemScriptResolver,
321    output_directory:   PathBuf,
322    graphviz_directory: Option<PathBuf>,
323    simulate:           bool,
324    written_paths:      Vec<PathBuf>,
325}
326
327impl DirectoryCompilerHost {
328    /// Creates one directory host rooted at `output_directory`.
329    #[must_use]
330    pub fn new(resolver: FileSystemScriptResolver, output_directory: impl Into<PathBuf>) -> Self {
331        Self {
332            resolver,
333            output_directory: output_directory.into(),
334            graphviz_directory: None,
335            simulate: false,
336            written_paths: Vec::new(),
337        }
338    }
339
340    /// Sets an alternate directory for Graphviz DOT output.
341    pub fn set_graphviz_directory(&mut self, directory: impl Into<PathBuf>) {
342        self.graphviz_directory = Some(directory.into());
343    }
344
345    /// Enables or disables simulate mode, which records target paths without
346    /// writing files.
347    pub fn set_simulate(&mut self, simulate: bool) {
348        self.simulate = simulate;
349    }
350
351    /// Returns the paths written or scheduled during the most recent compile.
352    #[must_use]
353    pub fn written_paths(&self) -> &[PathBuf] {
354        &self.written_paths
355    }
356}
357
358impl CompilerHost for DirectoryCompilerHost {
359    fn resolve_script_bytes(
360        &self,
361        script_name: &str,
362        res_type: ResType,
363    ) -> Result<Option<Vec<u8>>, SourceError> {
364        self.resolver.resolve_script_bytes(script_name, res_type)
365    }
366
367    fn write_file(
368        &mut self,
369        file_name: &str,
370        res_type: ResType,
371        data: &[u8],
372        _binary: bool,
373    ) -> Result<(), CompilerHostError> {
374        let path = self
375            .output_directory
376            .join(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{1}.{0}", get_res_ext(res_type),
                file_name))
    })format!("{file_name}.{}", get_res_ext(res_type)));
377        self.written_paths.push(path.clone());
378        if self.simulate {
379            return Ok(());
380        }
381        if let Some(parent) = path.parent() {
382            fs::create_dir_all(parent)?;
383        }
384        fs::write(&path, data)?;
385        Ok(())
386    }
387
388    fn write_graphviz(&mut self, file_name: &str, dot: &str) -> Result<(), CompilerHostError> {
389        let base = self
390            .graphviz_directory
391            .as_ref()
392            .unwrap_or(&self.output_directory);
393        let path = base.join(::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}.dot", file_name))
    })format!("{file_name}.dot"));
394        self.written_paths.push(path.clone());
395        if self.simulate {
396            return Ok(());
397        }
398        if let Some(parent) = path.parent() {
399            fs::create_dir_all(parent)?;
400        }
401        fs::write(&path, dot.as_bytes())?;
402        Ok(())
403    }
404}
405
406/// Options controlling multi-file directory and file compilation.
407#[derive(#[automatically_derived]
impl ::core::fmt::Debug for BatchCompileOptions {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        let names: &'static _ =
            &["driver", "search_roots", "recurse", "follow_symlinks",
                        "continue_on_error", "simulate", "output_directory",
                        "graphviz_directory"];
        let values: &[&dyn ::core::fmt::Debug] =
            &[&self.driver, &self.search_roots, &self.recurse,
                        &self.follow_symlinks, &self.continue_on_error,
                        &self.simulate, &self.output_directory,
                        &&self.graphviz_directory];
        ::core::fmt::Formatter::debug_struct_fields_finish(f,
            "BatchCompileOptions", names, values)
    }
}Debug, #[automatically_derived]
impl ::core::clone::Clone for BatchCompileOptions {
    #[inline]
    fn clone(&self) -> BatchCompileOptions {
        BatchCompileOptions {
            driver: ::core::clone::Clone::clone(&self.driver),
            search_roots: ::core::clone::Clone::clone(&self.search_roots),
            recurse: ::core::clone::Clone::clone(&self.recurse),
            follow_symlinks: ::core::clone::Clone::clone(&self.follow_symlinks),
            continue_on_error: ::core::clone::Clone::clone(&self.continue_on_error),
            simulate: ::core::clone::Clone::clone(&self.simulate),
            output_directory: ::core::clone::Clone::clone(&self.output_directory),
            graphviz_directory: ::core::clone::Clone::clone(&self.graphviz_directory),
        }
    }
}Clone, #[automatically_derived]
impl ::core::cmp::PartialEq for BatchCompileOptions {
    #[inline]
    fn eq(&self, other: &BatchCompileOptions) -> bool {
        self.recurse == other.recurse &&
                                    self.follow_symlinks == other.follow_symlinks &&
                                self.continue_on_error == other.continue_on_error &&
                            self.simulate == other.simulate &&
                        self.driver == other.driver &&
                    self.search_roots == other.search_roots &&
                self.output_directory == other.output_directory &&
            self.graphviz_directory == other.graphviz_directory
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for BatchCompileOptions {
    #[inline]
    #[doc(hidden)]
    #[coverage(off)]
    fn assert_fields_are_eq(&self) {
        let _: ::core::cmp::AssertParamIsEq<CompilerDriverOptions>;
        let _: ::core::cmp::AssertParamIsEq<Vec<PathBuf>>;
        let _: ::core::cmp::AssertParamIsEq<bool>;
        let _: ::core::cmp::AssertParamIsEq<Option<PathBuf>>;
        let _: ::core::cmp::AssertParamIsEq<Option<PathBuf>>;
    }
}Eq)]
408pub struct BatchCompileOptions {
409    /// Callback/session behavior for each compilation.
410    pub driver:             CompilerDriverOptions,
411    /// Extra filesystem roots used for langspec and include resolution.
412    pub search_roots:       Vec<PathBuf>,
413    /// Whether directory traversal should recurse.
414    pub recurse:            bool,
415    /// Whether directory traversal should follow symlinks.
416    pub follow_symlinks:    bool,
417    /// Whether compilation should continue after one file fails.
418    pub continue_on_error:  bool,
419    /// Whether outputs should be simulated without writing files.
420    pub simulate:           bool,
421    /// Optional output directory overriding each source file's parent.
422    pub output_directory:   Option<PathBuf>,
423    /// Optional directory for Graphviz DOT output.
424    pub graphviz_directory: Option<PathBuf>,
425}
426
427impl Default for BatchCompileOptions {
428    fn default() -> Self {
429        Self {
430            driver:             CompilerDriverOptions {
431                skip_missing_entrypoint: true,
432                ..CompilerDriverOptions::default()
433            },
434            search_roots:       Vec::new(),
435            recurse:            false,
436            follow_symlinks:    false,
437            continue_on_error:  false,
438            simulate:           false,
439            output_directory:   None,
440            graphviz_directory: None,
441        }
442    }
443}
444
445/// One per-input result from a batch compile run.
446#[derive(#[automatically_derived]
impl ::core::fmt::Debug for BatchCompileEntry {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field4_finish(f,
            "BatchCompileEntry", "input", &self.input, "status", &self.status,
            "outputs", &self.outputs, "error", &&self.error)
    }
}Debug, #[automatically_derived]
impl ::core::clone::Clone for BatchCompileEntry {
    #[inline]
    fn clone(&self) -> BatchCompileEntry {
        BatchCompileEntry {
            input: ::core::clone::Clone::clone(&self.input),
            status: ::core::clone::Clone::clone(&self.status),
            outputs: ::core::clone::Clone::clone(&self.outputs),
            error: ::core::clone::Clone::clone(&self.error),
        }
    }
}Clone, #[automatically_derived]
impl ::core::cmp::PartialEq for BatchCompileEntry {
    #[inline]
    fn eq(&self, other: &BatchCompileEntry) -> bool {
        self.input == other.input && self.status == other.status &&
                self.outputs == other.outputs && self.error == other.error
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for BatchCompileEntry {
    #[inline]
    #[doc(hidden)]
    #[coverage(off)]
    fn assert_fields_are_eq(&self) {
        let _: ::core::cmp::AssertParamIsEq<PathBuf>;
        let _: ::core::cmp::AssertParamIsEq<BatchCompileStatus>;
        let _: ::core::cmp::AssertParamIsEq<Vec<PathBuf>>;
        let _: ::core::cmp::AssertParamIsEq<Option<String>>;
    }
}Eq)]
447pub struct BatchCompileEntry {
448    /// Input file path.
449    pub input:   PathBuf,
450    /// Final status for this input.
451    pub status:  BatchCompileStatus,
452    /// Output paths written or scheduled by the host.
453    pub outputs: Vec<PathBuf>,
454    /// Human-readable error text when compilation failed.
455    pub error:   Option<String>,
456}
457
458/// One status emitted for a batch compile input.
459#[derive(#[automatically_derived]
impl ::core::fmt::Debug for BatchCompileStatus {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::write_str(f,
            match self {
                BatchCompileStatus::Success => "Success",
                BatchCompileStatus::Skipped => "Skipped",
                BatchCompileStatus::Error => "Error",
            })
    }
}Debug, #[automatically_derived]
impl ::core::clone::Clone for BatchCompileStatus {
    #[inline]
    fn clone(&self) -> BatchCompileStatus { *self }
}Clone, #[automatically_derived]
impl ::core::marker::Copy for BatchCompileStatus { }Copy, #[automatically_derived]
impl ::core::cmp::PartialEq for BatchCompileStatus {
    #[inline]
    fn eq(&self, other: &BatchCompileStatus) -> bool {
        let __self_discr = ::core::intrinsics::discriminant_value(self);
        let __arg1_discr = ::core::intrinsics::discriminant_value(other);
        __self_discr == __arg1_discr
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for BatchCompileStatus {
    #[inline]
    #[doc(hidden)]
    #[coverage(off)]
    fn assert_fields_are_eq(&self) {}
}Eq)]
460pub enum BatchCompileStatus {
461    /// Compilation succeeded.
462    Success,
463    /// The input was skipped because it has no entrypoint.
464    Skipped,
465    /// Compilation failed.
466    Error,
467}
468
469/// Summary of one batch compile run.
470#[derive(#[automatically_derived]
impl ::core::fmt::Debug for BatchCompileReport {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field4_finish(f,
            "BatchCompileReport", "entries", &self.entries, "successes",
            &self.successes, "skips", &self.skips, "errors", &&self.errors)
    }
}Debug, #[automatically_derived]
impl ::core::clone::Clone for BatchCompileReport {
    #[inline]
    fn clone(&self) -> BatchCompileReport {
        BatchCompileReport {
            entries: ::core::clone::Clone::clone(&self.entries),
            successes: ::core::clone::Clone::clone(&self.successes),
            skips: ::core::clone::Clone::clone(&self.skips),
            errors: ::core::clone::Clone::clone(&self.errors),
        }
    }
}Clone, #[automatically_derived]
impl ::core::cmp::PartialEq for BatchCompileReport {
    #[inline]
    fn eq(&self, other: &BatchCompileReport) -> bool {
        self.entries == other.entries && self.successes == other.successes &&
                self.skips == other.skips && self.errors == other.errors
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for BatchCompileReport {
    #[inline]
    #[doc(hidden)]
    #[coverage(off)]
    fn assert_fields_are_eq(&self) {
        let _: ::core::cmp::AssertParamIsEq<Vec<BatchCompileEntry>>;
        let _: ::core::cmp::AssertParamIsEq<usize>;
    }
}Eq, #[automatically_derived]
impl ::core::default::Default for BatchCompileReport {
    #[inline]
    fn default() -> BatchCompileReport {
        BatchCompileReport {
            entries: ::core::default::Default::default(),
            successes: ::core::default::Default::default(),
            skips: ::core::default::Default::default(),
            errors: ::core::default::Default::default(),
        }
    }
}Default)]
471pub struct BatchCompileReport {
472    /// Per-input results in compile order.
473    pub entries:   Vec<BatchCompileEntry>,
474    /// Number of successful inputs.
475    pub successes: usize,
476    /// Number of skipped inputs.
477    pub skips:     usize,
478    /// Number of failed inputs.
479    pub errors:    usize,
480}
481
482/// Errors returned before or outside individual compile attempts in batch mode.
483#[derive(#[automatically_derived]
impl ::core::fmt::Debug for BatchCompileError {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        match self {
            BatchCompileError::Io(__self_0) =>
                ::core::fmt::Formatter::debug_tuple_field1_finish(f, "Io",
                    &__self_0),
            BatchCompileError::Driver(__self_0) =>
                ::core::fmt::Formatter::debug_tuple_field1_finish(f, "Driver",
                    &__self_0),
        }
    }
}Debug)]
484pub enum BatchCompileError {
485    /// Directory traversal or output setup failed.
486    Io(io::Error),
487    /// One compile failed and `continue_on_error` was disabled.
488    Driver(CompilerDriverError),
489}
490
491impl fmt::Display for BatchCompileError {
492    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
493        match self {
494            Self::Io(error) => error.fmt(f),
495            Self::Driver(error) => error.fmt(f),
496        }
497    }
498}
499
500impl Error for BatchCompileError {}
501
502impl From<io::Error> for BatchCompileError {
503    fn from(value: io::Error) -> Self {
504        Self::Io(value)
505    }
506}
507
508impl From<CompilerDriverError> for BatchCompileError {
509    fn from(value: CompilerDriverError) -> Self {
510        Self::Driver(value)
511    }
512}
513
514/// Collects and compiles a set of script files and directories.
515///
516/// # Errors
517///
518/// Returns [`BatchCompileError`] if directory traversal fails or if one compile
519/// fails while `continue_on_error` is disabled.
520pub fn compile_paths(
521    paths: &[PathBuf],
522    options: &BatchCompileOptions,
523) -> Result<BatchCompileReport, BatchCompileError> {
524    let queue = collect_compile_inputs(paths, options)?;
525    let mut report = BatchCompileReport::default();
526
527    for input in queue {
528        let parent = input
529            .parent()
530            .map(Path::to_path_buf)
531            .unwrap_or_else(|| PathBuf::from("."));
532        let output_directory = options
533            .output_directory
534            .clone()
535            .unwrap_or_else(|| parent.clone());
536        let mut resolver = FileSystemScriptResolver::with_root(&parent);
537        for root in &options.search_roots {
538            resolver.add_root(root);
539        }
540        let mut host = DirectoryCompilerHost::new(resolver, output_directory);
541        if let Some(graphviz_directory) = &options.graphviz_directory {
542            host.set_graphviz_directory(graphviz_directory.clone());
543        }
544        host.set_simulate(options.simulate);
545
546        let mut driver = options.driver.clone();
547        driver.output_alias = input
548            .file_stem()
549            .and_then(|stem| stem.to_str())
550            .unwrap_or("scriptout")
551            .to_string();
552        if driver.graphviz_alias.is_none() {
553            driver.graphviz_alias = Some(driver.output_alias.clone());
554        }
555
556        match compile_file_with_host(&mut host, &input.to_string_lossy(), &driver) {
557            Ok(CompileFileOutcome::Compiled(_)) => {
558                report.successes += 1;
559                report.entries.push(BatchCompileEntry {
560                    input,
561                    status: BatchCompileStatus::Success,
562                    outputs: host.written_paths().to_vec(),
563                    error: None,
564                });
565            }
566            Ok(CompileFileOutcome::SkippedNoEntrypoint) => {
567                report.skips += 1;
568                report.entries.push(BatchCompileEntry {
569                    input,
570                    status: BatchCompileStatus::Skipped,
571                    outputs: host.written_paths().to_vec(),
572                    error: None,
573                });
574            }
575            Err(error) => {
576                let message = error.to_string();
577                report.errors += 1;
578                report.entries.push(BatchCompileEntry {
579                    input,
580                    status: BatchCompileStatus::Error,
581                    outputs: host.written_paths().to_vec(),
582                    error: Some(message),
583                });
584                if !options.continue_on_error {
585                    return Err(BatchCompileError::Driver(error));
586                }
587            }
588        }
589    }
590
591    Ok(report)
592}
593
594fn collect_compile_inputs(
595    paths: &[PathBuf],
596    options: &BatchCompileOptions,
597) -> Result<Vec<PathBuf>, io::Error> {
598    let mut queue = BTreeSet::new();
599    for path in paths {
600        collect_one(path, options, &mut queue)?;
601    }
602    Ok(queue.into_iter().collect())
603}
604
605fn collect_one(
606    path: &Path,
607    options: &BatchCompileOptions,
608    queue: &mut BTreeSet<PathBuf>,
609) -> Result<(), io::Error> {
610    if path.is_file() {
611        if can_compile_file(path) {
612            queue.insert(path.to_path_buf());
613        }
614        return Ok(());
615    }
616    if path.is_dir() {
617        for entry in fs::read_dir(path)? {
618            let entry = entry?;
619            let file_type = entry.file_type()?;
620            let entry_path = entry.path();
621            if file_type.is_symlink() && !options.follow_symlinks {
622                continue;
623            }
624            if file_type.is_dir() {
625                if options.recurse {
626                    collect_one(&entry_path, options, queue)?;
627                }
628            } else if file_type.is_file() && can_compile_file(&entry_path) {
629                queue.insert(entry_path);
630            }
631        }
632    }
633    Ok(())
634}
635
636fn can_compile_file(path: &Path) -> bool {
637    path.extension().and_then(|ext| ext.to_str()) == Some("nss")
638        && path.file_name().and_then(|name| name.to_str()) != Some("nwscript.nss")
639}
640
641#[cfg(test)]
642mod tests {
643    use std::{
644        collections::HashMap,
645        fs,
646        path::PathBuf,
647        time::{SystemTime, UNIX_EPOCH},
648    };
649
650    use nwnrs_types::resman::prelude::ResType;
651
652    use super::{
653        BatchCompileOptions, BatchCompileStatus, CompileFileOutcome, CompilerDriverOptions,
654        CompilerHost, CompilerHostError, FileSystemScriptResolver, compile_file_with_host,
655        compile_paths,
656    };
657    use crate::{NW_SCRIPT_SOURCE_RES_TYPE, ScriptResolver};
658
659    #[derive(Default)]
660    struct MemoryHost {
661        sources:  HashMap<(ResType, String), Vec<u8>>,
662        files:    Vec<(String, ResType, Vec<u8>)>,
663        graphviz: Vec<(String, String)>,
664    }
665
666    impl MemoryHost {
667        fn insert_source(&mut self, name: &str, contents: &str) {
668            self.sources.insert(
669                (NW_SCRIPT_SOURCE_RES_TYPE, name.to_ascii_lowercase()),
670                contents.as_bytes().to_vec(),
671            );
672        }
673    }
674
675    impl CompilerHost for MemoryHost {
676        fn resolve_script_bytes(
677            &self,
678            script_name: &str,
679            res_type: ResType,
680        ) -> Result<Option<Vec<u8>>, crate::SourceError> {
681            Ok(self
682                .sources
683                .get(&(res_type, script_name.to_ascii_lowercase()))
684                .cloned())
685        }
686
687        fn write_file(
688            &mut self,
689            file_name: &str,
690            res_type: ResType,
691            data: &[u8],
692            _binary: bool,
693        ) -> Result<(), CompilerHostError> {
694            self.files
695                .push((file_name.to_string(), res_type, data.to_vec()));
696            Ok(())
697        }
698
699        fn write_graphviz(&mut self, file_name: &str, dot: &str) -> Result<(), CompilerHostError> {
700            self.graphviz.push((file_name.to_string(), dot.to_string()));
701            Ok(())
702        }
703    }
704
705    fn unique_temp_dir(prefix: &str) -> PathBuf {
706        let nanos = SystemTime::now()
707            .duration_since(UNIX_EPOCH)
708            .unwrap_or_default()
709            .as_nanos();
710        std::env::temp_dir().join(format!("nwnrs-types-{prefix}-{nanos}"))
711    }
712
713    #[test]
714    fn compiles_through_callback_host_and_emits_graphviz() -> Result<(), Box<dyn std::error::Error>>
715    {
716        let mut host = MemoryHost::default();
717        host.insert_source("nwscript", "void PrintInteger(int n);");
718        host.insert_source("main", "void main() { PrintInteger(42); }");
719
720        let options = CompilerDriverOptions {
721            emit_graphviz: true,
722            output_alias: "main".to_string(),
723            ..CompilerDriverOptions::default()
724        };
725        let outcome = compile_file_with_host(&mut host, "main", &options)?;
726        assert!(matches!(outcome, CompileFileOutcome::Compiled(_)));
727        assert_eq!(host.files.len(), 2);
728        assert_eq!(host.graphviz.len(), 1);
729        assert_eq!(
730            host.graphviz
731                .first()
732                .map(|(_name, dot)| dot.contains("Function main")),
733            Some(true)
734        );
735        Ok(())
736    }
737
738    #[test]
739    fn batch_compiler_reports_success_skip_and_error() -> Result<(), Box<dyn std::error::Error>> {
740        let root = unique_temp_dir("batch");
741        fs::create_dir_all(&root)?;
742        fs::write(root.join("nwscript.nss"), "void PrintInteger(int n);")?;
743        fs::write(root.join("main.nss"), "void main() { PrintInteger(42); }")?;
744        fs::write(
745            root.join("helper.nss"),
746            "int AddOne(int n) { return n + 1; }",
747        )?;
748        fs::write(root.join("broken.nss"), "void main( {")?;
749
750        let mut options = BatchCompileOptions {
751            recurse: true,
752            continue_on_error: true,
753            simulate: true,
754            driver: CompilerDriverOptions {
755                emit_graphviz: true,
756                skip_missing_entrypoint: true,
757                ..CompilerDriverOptions::default()
758            },
759            ..BatchCompileOptions::default()
760        };
761        options.search_roots.push(root.clone());
762
763        let report = compile_paths(std::slice::from_ref(&root), &options)?;
764        assert_eq!(report.successes, 1);
765        assert_eq!(report.skips, 1);
766        assert_eq!(report.errors, 1);
767        assert!(
768            report
769                .entries
770                .iter()
771                .any(|entry| entry.status == BatchCompileStatus::Success)
772        );
773        assert!(
774            report
775                .entries
776                .iter()
777                .any(|entry| entry.status == BatchCompileStatus::Skipped)
778        );
779        assert!(
780            report
781                .entries
782                .iter()
783                .any(|entry| entry.status == BatchCompileStatus::Error)
784        );
785        fs::remove_dir_all(&root)?;
786        Ok(())
787    }
788
789    #[test]
790    fn filesystem_resolver_checks_roots_and_default_extension()
791    -> Result<(), Box<dyn std::error::Error>> {
792        let root = unique_temp_dir("resolver");
793        fs::create_dir_all(&root)?;
794        fs::write(root.join("test.nss"), "void main() {}")?;
795        let resolver = FileSystemScriptResolver::with_root(&root);
796        let resolved = resolver.resolve_script_bytes("test", NW_SCRIPT_SOURCE_RES_TYPE)?;
797        assert!(resolved.is_some());
798        fs::remove_dir_all(&root)?;
799        Ok(())
800    }
801}