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