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