Skip to main content

kcl_lib/execution/
cache.rs

1//! Functions for helping with caching an ast and finding the parts the changed.
2
3use std::sync::Arc;
4
5use indexmap::IndexMap;
6use itertools::EitherOrBoth;
7use itertools::Itertools;
8use kcl_api::Object;
9use kcl_api::ObjectId;
10use kittycad_modeling_cmds::KclVersion;
11use tokio::sync::RwLock;
12
13use crate::ExecOutcome;
14use crate::ExecutorContext;
15use crate::SourceRange;
16use crate::errors::KclError;
17use crate::execution::ConstraintKey;
18use crate::execution::ConstraintState;
19use crate::execution::EnvironmentRef;
20use crate::execution::ExecutorSettings;
21use crate::execution::KclValue;
22use crate::execution::KclValueView;
23use crate::execution::annotations;
24#[cfg(test)]
25use crate::execution::computed_kcl_version;
26use crate::execution::memory::Stack;
27use crate::execution::state::ModuleInfoMap;
28use crate::execution::state::NotYetAdded;
29use crate::execution::state::{self as exec_state};
30use crate::modules::ModuleId;
31use crate::modules::ModulePath;
32use crate::modules::ModuleSource;
33use crate::parsing::ast::types::Annotation;
34use crate::parsing::ast::types::Node;
35use crate::parsing::ast::types::Program;
36use crate::walk::Node as WalkNode;
37
38#[derive(Debug, Default)]
39struct CacheInner {
40    /// Last successful execution state for the cache.
41    pub old_ast: Option<GlobalState>,
42    // The last successful run's memory. Not cleared after an unsuccessful run.
43    pub prev_memory: Option<SketchModeState>,
44}
45
46/// Cached execution state for one engine session and its associated mock execution.
47///
48/// Clones share the same state. A default cache is empty and independent of all
49/// other caches. Callers must serialize executions that share an engine session;
50/// this cache only locks individual state accesses.
51#[derive(Debug, Clone)]
52pub struct Cache(Arc<dyn CacheStorage>);
53
54// Hide the cache's concrete state graph from Send/Sync proofs in callers.
55trait CacheStorage: std::fmt::Debug + Send + Sync {
56    fn inner(&self) -> &RwLock<CacheInner>;
57}
58
59impl CacheStorage for RwLock<CacheInner> {
60    fn inner(&self) -> &RwLock<CacheInner> {
61        self
62    }
63}
64
65impl Default for Cache {
66    fn default() -> Self {
67        Self(Arc::new(RwLock::new(CacheInner::default())))
68    }
69}
70
71impl Cache {
72    /// Read the old AST state from the cache.
73    pub(super) async fn read_old_ast(&self) -> Option<GlobalState> {
74        self.0.inner().read().await.old_ast.clone()
75    }
76
77    pub(super) async fn write_old_ast(&self, old_state: GlobalState) {
78        let mut inner = self.0.inner().write().await;
79        inner.old_ast = Some(old_state);
80    }
81
82    pub(crate) async fn read_old_memory(&self) -> Option<SketchModeState> {
83        self.0.inner().read().await.prev_memory.clone()
84    }
85
86    pub(crate) async fn write_old_memory(&self, mem: SketchModeState) {
87        let mut inner = self.0.inner().write().await;
88        inner.prev_memory = Some(mem);
89    }
90
91    /// Invalidate live execution state while preserving usable sketch-mode memory.
92    pub async fn bust_cache(&self) {
93        let mut inner = self.0.inner().write().await;
94        inner.old_ast = None;
95    }
96
97    /// Clear the memory reused by mock execution.
98    pub async fn clear_mem_cache(&self) {
99        let mut inner = self.0.inner().write().await;
100        inner.prev_memory = None;
101    }
102
103    /// Read a named value from the previous sketch-mode execution.
104    pub async fn read_old_memory_var(&self, name: &str) -> Option<KclValue> {
105        self.read_old_memory()
106            .await
107            .and_then(|memory| memory.stack.get(name, SourceRange::default()).ok())
108    }
109}
110
111/// Information for the caching an AST and smartly re-executing it if we can.
112#[derive(Debug, Clone)]
113pub struct CacheInformation<'a> {
114    pub ast: &'a Node<Program>,
115    pub settings: &'a ExecutorSettings,
116    /// The KCL version of this execution.
117    pub kcl_version: KclVersion,
118}
119
120impl<'a> CacheInformation<'a> {
121    #[cfg(test)]
122    fn new(ast: &'a Node<Program>, settings: &'a ExecutorSettings) -> Self {
123        // If the version couldn't be parsed, the default is used.
124        let kcl_version = computed_kcl_version(ast);
125        Self {
126            ast,
127            settings,
128            kcl_version,
129        }
130    }
131}
132
133/// The cached state of the whole program.
134#[derive(Debug, Clone)]
135pub(super) struct GlobalState {
136    pub(super) main: ModuleState,
137    /// The exec state.
138    pub(super) exec_state: exec_state::GlobalState,
139    /// The last settings used for execution.
140    pub(super) settings: ExecutorSettings,
141    /// The KCL version of this execution.
142    pub kcl_version: KclVersion,
143}
144
145impl GlobalState {
146    pub fn new(
147        state: exec_state::ExecState,
148        settings: ExecutorSettings,
149        kcl_version: KclVersion,
150        ast: Node<Program>,
151        result_env: EnvironmentRef,
152    ) -> Self {
153        Self {
154            main: ModuleState {
155                ast,
156                exec_state: state.mod_local,
157                result_env,
158            },
159            exec_state: state.global,
160            settings,
161            kcl_version,
162        }
163    }
164
165    pub fn with_settings(mut self, settings: ExecutorSettings) -> GlobalState {
166        self.settings = settings;
167        self
168    }
169
170    pub fn reconstitute_exec_state(&self, ctx: &ExecutorContext) -> exec_state::ExecState {
171        exec_state::ExecState {
172            execution_callbacks: ctx.execution_callbacks.clone(),
173            global: self.exec_state.clone(),
174            mod_local: self.main.exec_state.clone(),
175        }
176    }
177
178    pub async fn into_exec_outcome(self, ctx: &ExecutorContext) -> Result<ExecOutcome, KclError> {
179        // Fields are opt-in so that we don't accidentally leak private internal
180        // state when we add more to ExecState.
181        let variables = self.main.exec_state.variables(self.main.result_env)?;
182        #[cfg(test)]
183        let test_program_memory = variables.clone();
184        let variables = variables
185            .into_iter()
186            .map(|(key, value)| (key, KclValueView::from(value)))
187            .collect();
188        Ok(ExecOutcome {
189            variables,
190            filenames: self.exec_state.filenames(),
191            from_cache: false,
192            operations: self.exec_state.operations_by_module(),
193            artifact_graph: self.exec_state.artifacts.graph,
194            scene_objects: self.exec_state.root_module_artifacts.scene_objects,
195            source_range_to_object: self.exec_state.root_module_artifacts.source_range_to_object,
196            var_solutions: self.exec_state.root_module_artifacts.var_solutions,
197            refactor_metadata: self.exec_state.root_module_artifacts.refactor_metadata.clone(),
198            issues: self.exec_state.issues,
199            source_files: self.exec_state.id_to_source,
200            default_planes: ctx.engine.get_default_planes().read().await.clone(),
201            #[cfg(test)]
202            test_program_memory,
203        })
204    }
205
206    pub fn mock_memory_state(&self) -> Result<SketchModeState, KclError> {
207        let mut stack = self.main.exec_state.stack.deep_clone()?;
208        stack.restore_env(self.main.result_env)?;
209
210        Ok(SketchModeState {
211            stack,
212            module_infos: self.exec_state.module_infos.clone(),
213            path_to_source_id: self.exec_state.path_to_source_id.clone(),
214            id_to_source: self.exec_state.id_to_source.clone(),
215            never_type_ranges: self.exec_state.never_type_ranges.clone(),
216            constraint_state: self.main.exec_state.constraint_state.clone(),
217            scene_objects: self.exec_state.root_module_artifacts.scene_objects.clone(),
218            std_not_yet_added: self.exec_state.std_not_yet_added.clone(),
219            kcl_version: self.exec_state.entry_point_kcl_version.unwrap_or_default(),
220        })
221    }
222}
223
224/// Per-module cached state
225#[derive(Debug, Clone)]
226pub(super) struct ModuleState {
227    /// The AST of the module.
228    pub(super) ast: Node<Program>,
229    /// The ExecState of the module.
230    pub(super) exec_state: exec_state::ModuleState,
231    /// The memory env for the module.
232    pub(super) result_env: EnvironmentRef,
233}
234
235/// Cached state for sketch mode.
236#[derive(Debug, Clone)]
237pub(crate) struct SketchModeState {
238    /// The stack of the main module.
239    pub stack: Stack,
240    /// The module info map.
241    pub module_infos: ModuleInfoMap,
242    /// Map from source file path to module ID.
243    pub path_to_source_id: IndexMap<ModulePath, ModuleId>,
244    /// Map from module ID to source file contents.
245    pub id_to_source: IndexMap<ModuleId, ModuleSource>,
246    /// Deferred `never` type uses in imported local modules.
247    pub never_type_ranges: IndexMap<ModuleId, Vec<SourceRange>>,
248    /// Sticky per-constraint state persisted across sketch-mode mock solves.
249    pub constraint_state: IndexMap<ObjectId, IndexMap<ConstraintKey, ConstraintState>>,
250    /// The scene objects.
251    pub scene_objects: Vec<Object>,
252    /// See `GlobalState::std_not_yet_added`. Restored because a run reusing
253    /// this memory skips the prelude.
254    pub std_not_yet_added: IndexMap<String, NotYetAdded>,
255    /// The effective kclVersion (declared, or the default) of the program that
256    /// wrote this memory; see [`Self::reusable_for`].
257    pub kcl_version: KclVersion,
258}
259
260impl SketchModeState {
261    /// Whether a program with the effective `kcl_version` may reuse this
262    /// memory. Memory from another version keeps bindings, module outcomes,
263    /// and a prelude this program must not see, and the LSP worker reuses
264    /// memory with no other invalidation. Only the version counts: after other
265    /// settings changes the frontend may execute a single sketch, which needs
266    /// this memory and cannot rebuild it.
267    pub(crate) fn reusable_for(&self, kcl_version: KclVersion) -> bool {
268        self.kcl_version == kcl_version
269    }
270}
271
272#[cfg(test)]
273impl SketchModeState {
274    pub(crate) fn new_for_tests() -> Self {
275        Self {
276            stack: Stack::new_for_tests(),
277            module_infos: ModuleInfoMap::default(),
278            path_to_source_id: Default::default(),
279            id_to_source: Default::default(),
280            never_type_ranges: Default::default(),
281            constraint_state: Default::default(),
282            scene_objects: Vec::new(),
283            std_not_yet_added: Default::default(),
284            kcl_version: KclVersion::default(),
285        }
286    }
287}
288
289/// The result of a cache check.
290#[derive(Debug, Clone, PartialEq)]
291#[allow(clippy::large_enum_variant)]
292pub(super) enum CacheResult {
293    ReExecute {
294        /// Should we clear the scene and start over?
295        clear_scene: bool,
296        /// Do we need to reapply settings?
297        reapply_settings: bool,
298        /// The program that needs to be executed.
299        program: Node<Program>,
300    },
301    /// Check only the imports, and not the main program.
302    /// Before sending this we already checked the main program and it is the same.
303    /// And we made sure the import statements > 0.
304    CheckImportsOnly {
305        /// Argument is whether we need to reapply settings.
306        reapply_settings: bool,
307        /// The ast of the main file, which did not change.
308        ast: Node<Program>,
309    },
310    /// Argument is whether we need to reapply settings.
311    NoAction(bool),
312}
313
314/// Given an old ast, old program memory and new ast, find the parts of the code that need to be
315/// re-executed.
316/// This function should never error, because in the case of any internal error, we should just pop
317/// the cache.
318pub(super) async fn get_changed_program(old: CacheInformation<'_>, new: CacheInformation<'_>) -> CacheResult {
319    let mut reapply_settings = false;
320
321    if old.kcl_version != new.kcl_version {
322        // If the KCL version differs, we need to send the version and
323        // re-execute.
324        return CacheResult::ReExecute {
325            clear_scene: true,
326            reapply_settings: true,
327            program: new.ast.clone(),
328        };
329    }
330
331    // If the settings are different we might need to bust the cache.
332    // We specifically do this before checking if they are the exact same.
333    if old.settings != new.settings {
334        // If anything else is different we may not need to re-execute, but rather just
335        // run the settings again.
336        reapply_settings = true;
337    }
338
339    // If the ASTs are the EXACT same we return None.
340    // We don't even need to waste time computing the digests.
341    if old.ast == new.ast {
342        // First we need to make sure an imported file didn't change it's ast.
343        // We know they have the same imports because the ast is the same.
344        // If we have no imports, we can skip this.
345        if !old.ast.has_import_statements() {
346            return CacheResult::NoAction(reapply_settings);
347        }
348
349        // Tell the CacheResult we need to check all the imports, but the main ast is the same.
350        return CacheResult::CheckImportsOnly {
351            reapply_settings,
352            ast: old.ast.clone(),
353        };
354    }
355
356    // We have to clone just because the digests are stored inline :-(
357    let mut old_ast = old.ast.clone();
358    let mut new_ast = new.ast.clone();
359
360    // The digests should already be computed, but just in case we don't
361    // want to compare against none.
362    old_ast.compute_digest();
363    new_ast.compute_digest();
364
365    // Check if the digest is the same.
366    if old_ast.digest == new_ast.digest {
367        // First we need to make sure an imported file didn't change it's ast.
368        // We know they have the same imports because the ast is the same.
369        // If we have no imports, we can skip this.
370        if !old.ast.has_import_statements() {
371            return CacheResult::NoAction(reapply_settings);
372        }
373
374        // Tell the CacheResult we need to check all the imports, but the main ast is the same.
375        return CacheResult::CheckImportsOnly {
376            reapply_settings,
377            ast: old.ast.clone(),
378        };
379    }
380
381    // Check if the block annotations like @settings() are different.
382    if !old_ast
383        .inner_attrs
384        .iter()
385        .filter(annotations::is_significant)
386        .zip_longest(new_ast.inner_attrs.iter().filter(annotations::is_significant))
387        .all(|pair| {
388            match pair {
389                EitherOrBoth::Both(old, new) => {
390                    // Compare annotations, ignoring source ranges.  Digests must
391                    // have been computed before this.
392                    let Annotation { name, properties, .. } = &old.inner;
393                    let Annotation {
394                        name: new_name,
395                        properties: new_properties,
396                        ..
397                    } = &new.inner;
398
399                    name.as_ref().map(|n| n.digest) == new_name.as_ref().map(|n| n.digest)
400                        && properties
401                            .as_ref()
402                            .map(|props| props.iter().map(|p| p.digest).collect::<Vec<_>>())
403                            == new_properties
404                                .as_ref()
405                                .map(|props| props.iter().map(|p| p.digest).collect::<Vec<_>>())
406                }
407                _ => false,
408            }
409        })
410    {
411        // If any of the annotations are different at the beginning of the
412        // program, it's likely the settings, and we have to bust the cache and
413        // re-execute the whole thing.
414        return CacheResult::ReExecute {
415            clear_scene: true,
416            reapply_settings: true,
417            program: new.ast.clone(),
418        };
419    }
420
421    // Check if the changes were only to Non-code areas, like comments or whitespace.
422    generate_changed_program(old_ast, new_ast, reapply_settings)
423}
424
425/// Force-generate a new CacheResult, even if one shouldn't be made. The
426/// way in which this gets invoked should always be through
427/// [get_changed_program]. This is purely to contain the logic on
428/// how we construct a new [CacheResult].
429///
430/// A CacheResult's program may be a *diff* of only the parts that need
431/// to be executed (only in the case of "pure additions" at time of writing.).
432/// This diff-based AST should not be persisted or used anywhere beyond the execution flow,
433/// as it will be incomplete.
434///
435/// Digests *must* be computed before calling this.
436fn generate_changed_program(old_ast: Node<Program>, mut new_ast: Node<Program>, reapply_settings: bool) -> CacheResult {
437    if !old_ast.body.iter().zip(new_ast.body.iter()).all(|(old, new)| {
438        let old_node: WalkNode = old.into();
439        let new_node: WalkNode = new.into();
440        old_node.digest() == new_node.digest()
441    }) {
442        // If any of the nodes are different in the stretch of body that
443        // overlaps, we have to bust cache and rebuild the scene. This
444        // means a single insertion or deletion will result in a cache
445        // bust.
446
447        return CacheResult::ReExecute {
448            clear_scene: true,
449            reapply_settings,
450            program: new_ast,
451        };
452    }
453
454    // otherwise the overlapping section of the ast bodies matches.
455    // Let's see what the rest of the slice looks like.
456
457    match new_ast.body.len().cmp(&old_ast.body.len()) {
458        std::cmp::Ordering::Less => {
459            // the new AST is shorter than the old AST -- statements
460            // were removed from the "current" code in the "new" code.
461            //
462            // Statements up until now match which means this is a
463            // "pure delete" of the remaining slice, when we get to
464            // supporting that.
465
466            // Cache bust time.
467            CacheResult::ReExecute {
468                clear_scene: true,
469                reapply_settings,
470                program: new_ast,
471            }
472        }
473        std::cmp::Ordering::Greater => {
474            // the new AST is longer than the old AST, which means
475            // statements were added to the new code we haven't previously
476            // seen.
477            //
478            // Statements up until now are the same, which means this
479            // is a "pure addition" of the remaining slice.
480
481            new_ast.body = new_ast.body[old_ast.body.len()..].to_owned();
482
483            CacheResult::ReExecute {
484                clear_scene: false,
485                reapply_settings,
486                program: new_ast,
487            }
488        }
489        std::cmp::Ordering::Equal => {
490            // currently unreachable, but let's pretend like the code
491            // above can do something meaningful here for when we get
492            // to diffing and yanking chunks of the program apart.
493
494            // We don't actually want to do anything here; so we're going
495            // to not clear and do nothing. Is this wrong? I don't think
496            // so but i think many things. This def needs to change
497            // when the code above changes.
498
499            CacheResult::NoAction(reapply_settings)
500        }
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use pretty_assertions::assert_eq;
507
508    use super::*;
509    use crate::execution::ExecTestResults;
510    use crate::execution::parse_execute;
511    use crate::execution::parse_execute_with_project_dir;
512
513    #[tokio::test(flavor = "multi_thread")]
514    async fn test_get_changed_program_same_code() {
515        let new = r#"// Remove the end face for the extrusion.
516firstSketch = startSketchOn(XY)
517  |> startProfile(at = [-12, 12])
518  |> line(end = [24, 0])
519  |> line(end = [0, -24])
520  |> line(end = [-24, 0])
521  |> close()
522  |> extrude(length = 6)
523
524// Remove the end face for the extrusion.
525shell(firstSketch, faces = [END], thickness = 0.25)"#;
526
527        let ExecTestResults { program, exec_ctxt, .. } = parse_execute(new).await.unwrap();
528
529        let result = get_changed_program(
530            CacheInformation::new(&program.ast, &exec_ctxt.settings),
531            CacheInformation::new(&program.ast, &exec_ctxt.settings),
532        )
533        .await;
534
535        assert_eq!(result, CacheResult::NoAction(false));
536        exec_ctxt.close().await;
537    }
538
539    #[tokio::test(flavor = "multi_thread")]
540    async fn test_get_changed_program_same_code_changed_whitespace() {
541        let old = r#" // Remove the end face for the extrusion.
542firstSketch = startSketchOn(XY)
543  |> startProfile(at = [-12, 12])
544  |> line(end = [24, 0])
545  |> line(end = [0, -24])
546  |> line(end = [-24, 0])
547  |> close()
548  |> extrude(length = 6)
549
550// Remove the end face for the extrusion.
551shell(firstSketch, faces = [END], thickness = 0.25) "#;
552
553        let new = r#"// Remove the end face for the extrusion.
554firstSketch = startSketchOn(XY)
555  |> startProfile(at = [-12, 12])
556  |> line(end = [24, 0])
557  |> line(end = [0, -24])
558  |> line(end = [-24, 0])
559  |> close()
560  |> extrude(length = 6)
561
562// Remove the end face for the extrusion.
563shell(firstSketch, faces = [END], thickness = 0.25)"#;
564
565        let ExecTestResults { program, exec_ctxt, .. } = parse_execute(old).await.unwrap();
566
567        let program_new = crate::Program::parse_no_errs(new).unwrap();
568
569        let result = get_changed_program(
570            CacheInformation::new(&program.ast, &exec_ctxt.settings),
571            CacheInformation::new(&program_new.ast, &exec_ctxt.settings),
572        )
573        .await;
574
575        assert_eq!(result, CacheResult::NoAction(false));
576        exec_ctxt.close().await;
577    }
578
579    #[tokio::test(flavor = "multi_thread")]
580    async fn test_get_changed_program_same_code_changed_code_comment_start_of_program() {
581        let old = r#" // Removed the end face for the extrusion.
582firstSketch = startSketchOn(XY)
583  |> startProfile(at = [-12, 12])
584  |> line(end = [24, 0])
585  |> line(end = [0, -24])
586  |> line(end = [-24, 0])
587  |> close()
588  |> extrude(length = 6)
589
590// Remove the end face for the extrusion.
591shell(firstSketch, faces = [END], thickness = 0.25) "#;
592
593        let new = r#"// Remove the end face for the extrusion.
594firstSketch = startSketchOn(XY)
595  |> startProfile(at = [-12, 12])
596  |> line(end = [24, 0])
597  |> line(end = [0, -24])
598  |> line(end = [-24, 0])
599  |> close()
600  |> extrude(length = 6)
601
602// Remove the end face for the extrusion.
603shell(firstSketch, faces = [END], thickness = 0.25)"#;
604
605        let ExecTestResults { program, exec_ctxt, .. } = parse_execute(old).await.unwrap();
606
607        let program_new = crate::Program::parse_no_errs(new).unwrap();
608
609        let result = get_changed_program(
610            CacheInformation::new(&program.ast, &exec_ctxt.settings),
611            CacheInformation::new(&program_new.ast, &exec_ctxt.settings),
612        )
613        .await;
614
615        assert_eq!(result, CacheResult::NoAction(false));
616        exec_ctxt.close().await;
617    }
618
619    #[tokio::test(flavor = "multi_thread")]
620    async fn test_get_changed_program_same_code_changed_code_comments_attrs() {
621        let old = r#"@foo(whatever = whatever)
622@bar
623// Removed the end face for the extrusion.
624firstSketch = startSketchOn(XY)
625  |> startProfile(at = [-12, 12])
626  |> line(end = [24, 0])
627  |> line(end = [0, -24])
628  |> line(end = [-24, 0]) // my thing
629  |> close()
630  |> extrude(length = 6)
631
632// Remove the end face for the extrusion.
633shell(firstSketch, faces = [END], thickness = 0.25) "#;
634
635        let new = r#"@foo(whatever = 42)
636@baz
637// Remove the end face for the extrusion.
638firstSketch = startSketchOn(XY)
639  |> startProfile(at = [-12, 12])
640  |> line(end = [24, 0])
641  |> line(end = [0, -24])
642  |> line(end = [-24, 0])
643  |> close()
644  |> extrude(length = 6)
645
646// Remove the end face for the extrusion.
647shell(firstSketch, faces = [END], thickness = 0.25)"#;
648
649        let ExecTestResults { program, exec_ctxt, .. } = parse_execute(old).await.unwrap();
650
651        let program_new = crate::Program::parse_no_errs(new).unwrap();
652
653        let result = get_changed_program(
654            CacheInformation::new(&program.ast, &exec_ctxt.settings),
655            CacheInformation::new(&program_new.ast, &exec_ctxt.settings),
656        )
657        .await;
658
659        assert_eq!(result, CacheResult::NoAction(false));
660        exec_ctxt.close().await;
661    }
662
663    // Changing the grid settings with the exact same file should NOT bust the cache.
664    #[tokio::test(flavor = "multi_thread")]
665    async fn test_get_changed_program_same_code_but_different_grid_setting() {
666        let new = r#"// Remove the end face for the extrusion.
667firstSketch = startSketchOn(XY)
668  |> startProfile(at = [-12, 12])
669  |> line(end = [24, 0])
670  |> line(end = [0, -24])
671  |> line(end = [-24, 0])
672  |> close()
673  |> extrude(length = 6)
674
675// Remove the end face for the extrusion.
676shell(firstSketch, faces = [END], thickness = 0.25)"#;
677
678        let ExecTestResults {
679            program, mut exec_ctxt, ..
680        } = parse_execute(new).await.unwrap();
681
682        // Change the settings.
683        exec_ctxt.settings.show_grid = !exec_ctxt.settings.show_grid;
684
685        let result = get_changed_program(
686            CacheInformation::new(&program.ast, &Default::default()),
687            CacheInformation::new(&program.ast, &exec_ctxt.settings),
688        )
689        .await;
690
691        assert_eq!(result, CacheResult::NoAction(true));
692        exec_ctxt.close().await;
693    }
694
695    // Changing the edge visibility settings with the exact same file should NOT bust the cache.
696    #[tokio::test(flavor = "multi_thread")]
697    async fn test_get_changed_program_same_code_but_different_edge_visibility_setting() {
698        let new = r#"// Remove the end face for the extrusion.
699firstSketch = startSketchOn(XY)
700  |> startProfile(at = [-12, 12])
701  |> line(end = [24, 0])
702  |> line(end = [0, -24])
703  |> line(end = [-24, 0])
704  |> close()
705  |> extrude(length = 6)
706
707// Remove the end face for the extrusion.
708shell(firstSketch, faces = [END], thickness = 0.25)"#;
709
710        let ExecTestResults {
711            program, mut exec_ctxt, ..
712        } = parse_execute(new).await.unwrap();
713
714        // Change the settings.
715        exec_ctxt.settings.highlight_edges = !exec_ctxt.settings.highlight_edges;
716
717        let result = get_changed_program(
718            CacheInformation::new(&program.ast, &Default::default()),
719            CacheInformation::new(&program.ast, &exec_ctxt.settings),
720        )
721        .await;
722
723        assert_eq!(result, CacheResult::NoAction(true));
724
725        // Change the settings back.
726        let old_settings = exec_ctxt.settings.clone();
727        exec_ctxt.settings.highlight_edges = !exec_ctxt.settings.highlight_edges;
728
729        let result = get_changed_program(
730            CacheInformation::new(&program.ast, &old_settings),
731            CacheInformation::new(&program.ast, &exec_ctxt.settings),
732        )
733        .await;
734
735        assert_eq!(result, CacheResult::NoAction(true));
736
737        // Change the settings back.
738        let old_settings = exec_ctxt.settings.clone();
739        exec_ctxt.settings.highlight_edges = !exec_ctxt.settings.highlight_edges;
740
741        let result = get_changed_program(
742            CacheInformation::new(&program.ast, &old_settings),
743            CacheInformation::new(&program.ast, &exec_ctxt.settings),
744        )
745        .await;
746
747        assert_eq!(result, CacheResult::NoAction(true));
748        exec_ctxt.close().await;
749    }
750
751    // Changing only the KCL version must invalidate the cache and re-execute the whole program.
752    #[tokio::test(flavor = "multi_thread")]
753    async fn test_get_changed_program_same_code_but_different_kcl_version_using_annotation() {
754        for (old_version, new_version) in [
755            ("2.0", "\"3.0-preview\""),
756            ("2.0", "3.0"),
757            ("3.0", "2.0"),
758            ("\"3.0-preview\"", "3.0"),
759            ("3.0", "\"3.0-preview\""),
760        ] {
761            let old_code = format!("@settings(kclVersion = {old_version})\nx = 1\ny = x + 1\n");
762            let new_code = format!("@settings(kclVersion = {new_version})\nx = 1\ny = x + 1\n");
763            let mut program = crate::Program::parse_no_errs(&old_code).unwrap();
764            program.compute_digest();
765            let mut new_program = crate::Program::parse_no_errs(&new_code).unwrap();
766            new_program.compute_digest();
767            let settings = ExecutorSettings::default();
768
769            let result = get_changed_program(
770                CacheInformation::new(&program.ast, &settings),
771                CacheInformation::new(&new_program.ast, &settings),
772            )
773            .await;
774
775            assert_eq!(
776                result,
777                CacheResult::ReExecute {
778                    clear_scene: true,
779                    reapply_settings: true,
780                    program: new_program.ast,
781                },
782                "{old_version} -> {new_version}"
783            );
784        }
785    }
786
787    // Changing the units settings using an annotation with the exact same file
788    // should bust the cache.
789    #[tokio::test(flavor = "multi_thread")]
790    async fn test_get_changed_program_same_code_but_different_unit_setting_using_annotation() {
791        let old_code = r#"@settings(defaultLengthUnit = in)
792startSketchOn(XY)
793"#;
794        let new_code = r#"@settings(defaultLengthUnit = mm)
795startSketchOn(XY)
796"#;
797
798        let ExecTestResults { program, exec_ctxt, .. } = parse_execute(old_code).await.unwrap();
799
800        let mut new_program = crate::Program::parse_no_errs(new_code).unwrap();
801        new_program.compute_digest();
802
803        let result = get_changed_program(
804            CacheInformation::new(&program.ast, &exec_ctxt.settings),
805            CacheInformation::new(&new_program.ast, &exec_ctxt.settings),
806        )
807        .await;
808
809        assert_eq!(
810            result,
811            CacheResult::ReExecute {
812                clear_scene: true,
813                reapply_settings: true,
814                program: new_program.ast,
815            }
816        );
817        exec_ctxt.close().await;
818    }
819
820    // Removing the units settings using an annotation, when it was non-default
821    // units, with the exact same file should bust the cache.
822    #[tokio::test(flavor = "multi_thread")]
823    async fn test_get_changed_program_same_code_but_removed_unit_setting_using_annotation() {
824        let old_code = r#"@settings(defaultLengthUnit = in)
825startSketchOn(XY)
826"#;
827        let new_code = r#"
828startSketchOn(XY)
829"#;
830
831        let ExecTestResults { program, exec_ctxt, .. } = parse_execute(old_code).await.unwrap();
832
833        let mut new_program = crate::Program::parse_no_errs(new_code).unwrap();
834        new_program.compute_digest();
835
836        let result = get_changed_program(
837            CacheInformation::new(&program.ast, &exec_ctxt.settings),
838            CacheInformation::new(&new_program.ast, &exec_ctxt.settings),
839        )
840        .await;
841
842        assert_eq!(
843            result,
844            CacheResult::ReExecute {
845                clear_scene: true,
846                reapply_settings: true,
847                program: new_program.ast,
848            }
849        );
850        exec_ctxt.close().await;
851    }
852
853    #[tokio::test(flavor = "multi_thread")]
854    async fn test_multi_file_no_changes_does_not_reexecute() {
855        let code = r#"import "toBeImported.kcl" as importedCube
856
857importedCube
858
859sketch001 = startSketchOn(XZ)
860profile001 = startProfile(sketch001, at = [-134.53, -56.17])
861  |> angledLine(angle = 0, length = 79.05, tag = $rectangleSegmentA001)
862  |> angledLine(angle = segAng(rectangleSegmentA001) - 90, length = 76.28)
863  |> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001), tag = $seg01)
864  |> line(endAbsolute = [profileStartX(%), profileStartY(%)], tag = $seg02)
865  |> close()
866extrude001 = extrude(profile001, length = 100)
867sketch003 = startSketchOn(extrude001, face = seg02)
868sketch002 = startSketchOn(extrude001, face = seg01)
869"#;
870
871        let other_file = (
872            std::path::PathBuf::from("toBeImported.kcl"),
873            r#"sketch001 = startSketchOn(XZ)
874profile001 = startProfile(sketch001, at = [281.54, 305.81])
875  |> angledLine(angle = 0, length = 123.43, tag = $rectangleSegmentA001)
876  |> angledLine(angle = segAng(rectangleSegmentA001) - 90, length = 85.99)
877  |> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001))
878  |> line(endAbsolute = [profileStartX(%), profileStartY(%)])
879  |> close()
880extrude(profile001, length = 100)"#
881                .to_string(),
882        );
883
884        let tmp_dir = std::env::temp_dir();
885        let tmp_dir = tmp_dir.join(uuid::Uuid::new_v4().to_string());
886
887        // Create a temporary file for each of the other files.
888        let tmp_file = tmp_dir.join(other_file.0);
889        std::fs::create_dir_all(tmp_file.parent().unwrap()).unwrap();
890        std::fs::write(tmp_file, other_file.1).unwrap();
891
892        let ExecTestResults { program, exec_ctxt, .. } =
893            parse_execute_with_project_dir(code, Some(crate::TypedPath(tmp_dir)))
894                .await
895                .unwrap();
896
897        let mut new_program = crate::Program::parse_no_errs(code).unwrap();
898        new_program.compute_digest();
899
900        let result = get_changed_program(
901            CacheInformation::new(&program.ast, &exec_ctxt.settings),
902            CacheInformation::new(&new_program.ast, &exec_ctxt.settings),
903        )
904        .await;
905
906        let CacheResult::CheckImportsOnly { reapply_settings, .. } = result else {
907            panic!("Expected CheckImportsOnly, got {result:?}");
908        };
909
910        assert_eq!(reapply_settings, false);
911        exec_ctxt.close().await;
912    }
913
914    #[tokio::test(flavor = "multi_thread")]
915    async fn test_cache_multi_file_only_other_file_changes_should_reexecute() {
916        let code = r#"import "toBeImported.kcl" as importedCube
917
918importedCube
919
920sketch001 = startSketchOn(XZ)
921profile001 = startProfile(sketch001, at = [-134.53, -56.17])
922  |> angledLine(angle = 0, length = 79.05, tag = $rectangleSegmentA001)
923  |> angledLine(angle = segAng(rectangleSegmentA001) - 90, length = 76.28)
924  |> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001), tag = $seg01)
925  |> line(endAbsolute = [profileStartX(%), profileStartY(%)], tag = $seg02)
926  |> close()
927extrude001 = extrude(profile001, length = 100)
928sketch003 = startSketchOn(extrude001, face = seg02)
929sketch002 = startSketchOn(extrude001, face = seg01)
930"#;
931
932        let other_file = (
933            std::path::PathBuf::from("toBeImported.kcl"),
934            r#"sketch001 = startSketchOn(XZ)
935profile001 = startProfile(sketch001, at = [281.54, 305.81])
936  |> angledLine(angle = 0, length = 123.43, tag = $rectangleSegmentA001)
937  |> angledLine(angle = segAng(rectangleSegmentA001) - 90, length = 85.99)
938  |> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001))
939  |> line(endAbsolute = [profileStartX(%), profileStartY(%)])
940  |> close()
941extrude(profile001, length = 100)"#
942                .to_string(),
943        );
944
945        let other_file2 = (
946            std::path::PathBuf::from("toBeImported.kcl"),
947            r#"sketch001 = startSketchOn(XZ)
948profile001 = startProfile(sketch001, at = [281.54, 305.81])
949  |> angledLine(angle = 0, length = 123.43, tag = $rectangleSegmentA001)
950  |> angledLine(angle = segAng(rectangleSegmentA001) - 90, length = 85.99)
951  |> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001))
952  |> line(endAbsolute = [profileStartX(%), profileStartY(%)])
953  |> close()
954extrude(profile001, length = 100)
955|> translate(z=100) 
956"#
957            .to_string(),
958        );
959
960        let tmp_dir = std::env::temp_dir();
961        let tmp_dir = tmp_dir.join(uuid::Uuid::new_v4().to_string());
962
963        // Create a temporary file for each of the other files.
964        let tmp_file = tmp_dir.join(other_file.0);
965        std::fs::create_dir_all(tmp_file.parent().unwrap()).unwrap();
966        std::fs::write(&tmp_file, other_file.1).unwrap();
967
968        let ExecTestResults { program, exec_ctxt, .. } =
969            parse_execute_with_project_dir(code, Some(crate::TypedPath(tmp_dir)))
970                .await
971                .unwrap();
972
973        // Change the other file.
974        std::fs::write(tmp_file, other_file2.1).unwrap();
975
976        let mut new_program = crate::Program::parse_no_errs(code).unwrap();
977        new_program.compute_digest();
978
979        let result = get_changed_program(
980            CacheInformation::new(&program.ast, &exec_ctxt.settings),
981            CacheInformation::new(&new_program.ast, &exec_ctxt.settings),
982        )
983        .await;
984
985        let CacheResult::CheckImportsOnly { reapply_settings, .. } = result else {
986            panic!("Expected CheckImportsOnly, got {result:?}");
987        };
988
989        assert_eq!(reapply_settings, false);
990        exec_ctxt.close().await;
991    }
992
993    #[tokio::test(flavor = "multi_thread")]
994    async fn test_get_changed_program_added_outer_attribute() {
995        let old_code = r#"import "tests/inputs/cube.step"
996"#;
997        let new_code = r#"@(coords = opengl)
998import "tests/inputs/cube.step"
999"#;
1000
1001        let ExecTestResults { program, exec_ctxt, .. } = parse_execute(old_code).await.unwrap();
1002
1003        let mut new_program = crate::Program::parse_no_errs(new_code).unwrap();
1004        new_program.compute_digest();
1005
1006        let result = get_changed_program(
1007            CacheInformation::new(&program.ast, &exec_ctxt.settings),
1008            CacheInformation::new(&new_program.ast, &exec_ctxt.settings),
1009        )
1010        .await;
1011
1012        assert_eq!(
1013            result,
1014            CacheResult::ReExecute {
1015                clear_scene: true,
1016                reapply_settings: false,
1017                program: new_program.ast,
1018            }
1019        );
1020        exec_ctxt.close().await;
1021    }
1022
1023    #[tokio::test(flavor = "multi_thread")]
1024    async fn test_get_changed_program_different_outer_attribute() {
1025        let old_code = r#"@(coords = vulkan)
1026import "tests/inputs/cube.step"
1027"#;
1028        let new_code = r#"@(coords = opengl)
1029import "tests/inputs/cube.step"
1030"#;
1031
1032        let ExecTestResults { program, exec_ctxt, .. } = parse_execute(old_code).await.unwrap();
1033
1034        let mut new_program = crate::Program::parse_no_errs(new_code).unwrap();
1035        new_program.compute_digest();
1036
1037        let result = get_changed_program(
1038            CacheInformation::new(&program.ast, &exec_ctxt.settings),
1039            CacheInformation::new(&new_program.ast, &exec_ctxt.settings),
1040        )
1041        .await;
1042
1043        assert_eq!(
1044            result,
1045            CacheResult::ReExecute {
1046                clear_scene: true,
1047                reapply_settings: false,
1048                program: new_program.ast,
1049            }
1050        );
1051        exec_ctxt.close().await;
1052    }
1053}