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