Skip to main content

kcl_lib/execution/
cache.rs

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