Compare commits
	
		
			4 Commits
		
	
	
		
			kcl-0.1.47
			...
			achalmers/
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| f3cb24cad7 | |||
| 4631b1e74d | |||
| 06d0fa1da5 | |||
| 6427b9da48 | 
| @ -1520,15 +1520,15 @@ const key = 'c'` | ||||
|       }, | ||||
|     } | ||||
|     const { nonCodeMeta } = parse(code) | ||||
|     expect(nonCodeMeta.nonCodeNodes[0]).toEqual(nonCodeMetaInstance) | ||||
|     expect(nonCodeMeta.nonCodeNodes[0][0]).toEqual(nonCodeMetaInstance) | ||||
|  | ||||
|     // extra whitespace won't change it's position (0) or value (NB the start end would have changed though) | ||||
|     const codeWithExtraStartWhitespace = '\n\n\n' + code | ||||
|     const { nonCodeMeta: nonCodeMeta2 } = parse(codeWithExtraStartWhitespace) | ||||
|     expect(nonCodeMeta2.nonCodeNodes[0].value).toStrictEqual( | ||||
|     expect(nonCodeMeta2.nonCodeNodes[0][0].value).toStrictEqual( | ||||
|       nonCodeMetaInstance.value | ||||
|     ) | ||||
|     expect(nonCodeMeta2.nonCodeNodes[0].start).not.toBe( | ||||
|     expect(nonCodeMeta2.nonCodeNodes[0][0].start).not.toBe( | ||||
|       nonCodeMetaInstance.start | ||||
|     ) | ||||
|   }) | ||||
| @ -1545,7 +1545,7 @@ const key = 'c'` | ||||
|     const { body } = parse(code) | ||||
|     const indexOfSecondLineToExpression = 2 | ||||
|     const sketchNonCodeMeta = (body as any)[0].declarations[0].init.nonCodeMeta | ||||
|       .nonCodeNodes | ||||
|       .nonCodeNodes[0] | ||||
|     expect(sketchNonCodeMeta[indexOfSecondLineToExpression]).toEqual({ | ||||
|       type: 'NonCodeNode', | ||||
|       start: 106, | ||||
| @ -1568,7 +1568,7 @@ const key = 'c'` | ||||
|  | ||||
|     const { body } = parse(code) | ||||
|     const sketchNonCodeMeta = (body[0] as any).declarations[0].init.nonCodeMeta | ||||
|       .nonCodeNodes | ||||
|       .nonCodeNodes[0] | ||||
|     expect(sketchNonCodeMeta[3]).toEqual({ | ||||
|       type: 'NonCodeNode', | ||||
|       start: 125, | ||||
|  | ||||
| @ -6,36 +6,30 @@ pub fn bench_lex(c: &mut Criterion) { | ||||
|     c.bench_function("lex_pipes_on_pipes", |b| b.iter(|| lex(PIPES_PROGRAM))); | ||||
| } | ||||
|  | ||||
| pub fn bench_lex_parse(c: &mut Criterion) { | ||||
|     c.bench_function("parse_lex_cube", |b| b.iter(|| lex_and_parse(CUBE_PROGRAM))); | ||||
|     c.bench_function("parse_lex_big_kitt", |b| b.iter(|| lex_and_parse(KITT_PROGRAM))); | ||||
|     c.bench_function("parse_lex_pipes_on_pipes", |b| b.iter(|| lex_and_parse(PIPES_PROGRAM))); | ||||
| pub fn bench_parse(c: &mut Criterion) { | ||||
|     for (name, file) in [ | ||||
|         ("pipes_on_pipes", PIPES_PROGRAM), | ||||
|         ("big_kitt", KITT_PROGRAM), | ||||
|         ("cube", CUBE_PROGRAM), | ||||
|     ] { | ||||
|         let tokens = kcl_lib::token::lexer(file); | ||||
|         c.bench_function(&format!("parse_{name}"), move |b| { | ||||
|             let tok = tokens.clone(); | ||||
|             b.iter(move || { | ||||
|                 let parser = kcl_lib::parser::Parser::new(tok.clone()); | ||||
|                 black_box(parser.ast().unwrap()); | ||||
|             }) | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn lex(program: &str) { | ||||
|     black_box(kcl_lib::token::lexer(program)); | ||||
| } | ||||
|  | ||||
| fn lex_and_parse(program: &str) { | ||||
|     let tokens = kcl_lib::token::lexer(program); | ||||
|     let parser = kcl_lib::parser::Parser::new(tokens); | ||||
|     black_box(parser.ast().unwrap()); | ||||
| } | ||||
|  | ||||
| criterion_group!(benches, bench_lex, bench_lex_parse); | ||||
| criterion_group!(benches, bench_lex, bench_parse); | ||||
| criterion_main!(benches); | ||||
|  | ||||
| const KITT_PROGRAM: &str = include_str!("../../tests/executor/inputs/kittycad_svg.kcl"); | ||||
| const PIPES_PROGRAM: &str = include_str!("../../tests/executor/inputs/pipes_on_pipes.kcl"); | ||||
| const CUBE_PROGRAM: &str = r#"fn cube = (pos, scale) => { | ||||
|   const sg = startSketchAt(pos) | ||||
|     |> line([0, scale], %) | ||||
|     |> line([scale, 0], %) | ||||
|     |> line([0, -scale], %) | ||||
|  | ||||
|   return sg | ||||
| } | ||||
|  | ||||
| const b1 = cube([0,0], 10) | ||||
| const pt1 = b1[0] | ||||
| show(b1)"#; | ||||
| const CUBE_PROGRAM: &str = include_str!("../../tests/executor/inputs/cube.kcl"); | ||||
|  | ||||
| @ -82,7 +82,10 @@ impl Program { | ||||
|                     }; | ||||
|  | ||||
|                     let custom_white_space_or_comment = match self.non_code_meta.non_code_nodes.get(&index) { | ||||
|                         Some(custom_white_space_or_comment) => custom_white_space_or_comment.format(&indentation), | ||||
|                         Some(noncodes) => noncodes | ||||
|                             .iter() | ||||
|                             .map(|custom_white_space_or_comment| custom_white_space_or_comment.format(&indentation)) | ||||
|                             .collect::<String>(), | ||||
|                         None => String::new(), | ||||
|                     }; | ||||
|                     let end_string = if custom_white_space_or_comment.is_empty() { | ||||
| @ -707,30 +710,35 @@ pub struct NonCodeNode { | ||||
| impl NonCodeNode { | ||||
|     pub fn value(&self) -> String { | ||||
|         match &self.value { | ||||
|             NonCodeValue::InlineComment { value } => value.clone(), | ||||
|             NonCodeValue::BlockComment { value } => value.clone(), | ||||
|             NonCodeValue::NewLineBlockComment { value } => value.clone(), | ||||
|             NonCodeValue::InlineComment { value, style: _ } => value.clone(), | ||||
|             NonCodeValue::BlockComment { value, style: _ } => value.clone(), | ||||
|             NonCodeValue::NewLineBlockComment { value, style: _ } => value.clone(), | ||||
|             NonCodeValue::NewLine => "\n\n".to_string(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn format(&self, indentation: &str) -> String { | ||||
|         match &self.value { | ||||
|             NonCodeValue::InlineComment { value } => format!(" // {}\n", value), | ||||
|             NonCodeValue::BlockComment { value } => { | ||||
|             NonCodeValue::InlineComment { | ||||
|                 value, | ||||
|                 style: CommentStyle::Line, | ||||
|             } => format!(" // {}\n", value), | ||||
|             NonCodeValue::InlineComment { | ||||
|                 value, | ||||
|                 style: CommentStyle::Block, | ||||
|             } => format!(" /* {} */", value), | ||||
|             NonCodeValue::BlockComment { value, style } => { | ||||
|                 let add_start_new_line = if self.start == 0 { "" } else { "\n" }; | ||||
|                 if value.contains('\n') { | ||||
|                     format!("{}{}/* {} */\n", add_start_new_line, indentation, value) | ||||
|                 } else { | ||||
|                     format!("{}{}// {}\n", add_start_new_line, indentation, value) | ||||
|                 match style { | ||||
|                     CommentStyle::Block => format!("{}{}/* {} */\n", add_start_new_line, indentation, value), | ||||
|                     CommentStyle::Line => format!("{}{}// {}\n", add_start_new_line, indentation, value), | ||||
|                 } | ||||
|             } | ||||
|             NonCodeValue::NewLineBlockComment { value } => { | ||||
|             NonCodeValue::NewLineBlockComment { value, style } => { | ||||
|                 let add_start_new_line = if self.start == 0 { "" } else { "\n\n" }; | ||||
|                 if value.contains('\n') { | ||||
|                     format!("{}{}/* {} */\n", add_start_new_line, indentation, value) | ||||
|                 } else { | ||||
|                     format!("{}{}// {}\n", add_start_new_line, indentation, value) | ||||
|                 match style { | ||||
|                     CommentStyle::Block => format!("{}{}/* {} */\n", add_start_new_line, indentation, value), | ||||
|                     CommentStyle::Line => format!("{}{}// {}\n", add_start_new_line, indentation, value), | ||||
|                 } | ||||
|             } | ||||
|             NonCodeValue::NewLine => "\n\n".to_string(), | ||||
| @ -738,14 +746,27 @@ impl NonCodeNode { | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)] | ||||
| #[ts(export)] | ||||
| #[serde(tag = "type", rename_all = "camelCase")] | ||||
| pub enum CommentStyle { | ||||
|     /// Like // foo | ||||
|     Line, | ||||
|     /// Like /* foo */ | ||||
|     Block, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)] | ||||
| #[ts(export)] | ||||
| #[serde(tag = "type", rename_all = "camelCase")] | ||||
| pub enum NonCodeValue { | ||||
|     /// An inline comment. | ||||
|     /// An example of this is the following: `1 + 1 // This is an inline comment`. | ||||
|     /// Here are examples: | ||||
|     /// `1 + 1 // This is an inline comment`. | ||||
|     /// `1 + 1 /* Here's another */`. | ||||
|     InlineComment { | ||||
|         value: String, | ||||
|         style: CommentStyle, | ||||
|     }, | ||||
|     /// A block comment. | ||||
|     /// An example of this is the following: | ||||
| @ -759,11 +780,13 @@ pub enum NonCodeValue { | ||||
|     /// If it did it would be a `NewLineBlockComment`. | ||||
|     BlockComment { | ||||
|         value: String, | ||||
|         style: CommentStyle, | ||||
|     }, | ||||
|     /// A block comment that has a new line above it. | ||||
|     /// The user explicitly added a new line above the block comment. | ||||
|     NewLineBlockComment { | ||||
|         value: String, | ||||
|         style: CommentStyle, | ||||
|     }, | ||||
|     // A new line like `\n\n` NOT a new line like `\n`. | ||||
|     // This is also not a comment. | ||||
| @ -774,7 +797,7 @@ pub enum NonCodeValue { | ||||
| #[ts(export)] | ||||
| #[serde(rename_all = "camelCase")] | ||||
| pub struct NonCodeMeta { | ||||
|     pub non_code_nodes: HashMap<usize, NonCodeNode>, | ||||
|     pub non_code_nodes: HashMap<usize, Vec<NonCodeNode>>, | ||||
|     pub start: Option<NonCodeNode>, | ||||
| } | ||||
|  | ||||
| @ -795,7 +818,10 @@ impl<'de> Deserialize<'de> for NonCodeMeta { | ||||
|         let helper = NonCodeMetaHelper::deserialize(deserializer)?; | ||||
|         let mut non_code_nodes = HashMap::new(); | ||||
|         for (key, value) in helper.non_code_nodes { | ||||
|             non_code_nodes.insert(key.parse().map_err(serde::de::Error::custom)?, value); | ||||
|             non_code_nodes | ||||
|                 .entry(key.parse().map_err(serde::de::Error::custom)?) | ||||
|                 .or_insert(Vec::new()) | ||||
|                 .push(value); | ||||
|         } | ||||
|         Ok(NonCodeMeta { | ||||
|             non_code_nodes, | ||||
| @ -804,6 +830,12 @@ impl<'de> Deserialize<'de> for NonCodeMeta { | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl NonCodeMeta { | ||||
|     pub fn insert(&mut self, i: usize, new: NonCodeNode) { | ||||
|         self.non_code_nodes.entry(i).or_default().push(new); | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)] | ||||
| #[ts(export)] | ||||
| #[serde(tag = "type")] | ||||
| @ -2385,7 +2417,9 @@ impl PipeExpression { | ||||
|                 let mut s = statement.recast(options, indentation_level + 1, true); | ||||
|                 let non_code_meta = self.non_code_meta.clone(); | ||||
|                 if let Some(non_code_meta_value) = non_code_meta.non_code_nodes.get(&index) { | ||||
|                     s += non_code_meta_value.format(&indentation).trim_end_matches('\n') | ||||
|                     for val in non_code_meta_value { | ||||
|                         s += val.format(&indentation).trim_end_matches('\n') | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 if index != self.body.len() - 1 { | ||||
| @ -2869,9 +2903,9 @@ show(part001)"#; | ||||
|             recasted, | ||||
|             r#"fn myFn = () => { | ||||
|   // this is a comment | ||||
|   const yo = { a: { b: { c: '123' } } } | ||||
|   /* block | ||||
|   const yo = { a: { b: { c: '123' } } } /* block | ||||
|   comment */ | ||||
|  | ||||
|   const key = 'c' | ||||
|   // this is also a comment | ||||
|   return things | ||||
| @ -2913,9 +2947,8 @@ const mySk1 = startSketchOn('XY') | ||||
|   |> lineTo({ to: [0, 1], tag: 'myTag' }, %) | ||||
|   |> lineTo([1, 1], %) | ||||
|   /* and | ||||
|   here | ||||
|  | ||||
| a comment between pipe expression statements */ | ||||
|   here */ | ||||
|   // a comment between pipe expression statements | ||||
|   |> rx(90, %) | ||||
|   // and another with just white space between others below | ||||
|   |> ry(45, %) | ||||
| @ -2988,16 +3021,19 @@ const things = "things" | ||||
|         let program = parser.ast().unwrap(); | ||||
|  | ||||
|         let recasted = program.recast(&Default::default(), 0); | ||||
|         assert_eq!(recasted.trim(), some_program_string.trim()); | ||||
|         let expected = some_program_string.trim(); | ||||
|         // Currently new parser removes an empty line | ||||
|         let actual = recasted.trim(); | ||||
|         assert_eq!(actual, expected); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_recast_comment_tokens_inside_strings() { | ||||
|         let some_program_string = r#"let b = { | ||||
|   "end": 141, | ||||
|   "start": 125, | ||||
|   "type": "NonCodeNode", | ||||
|   "value": " | ||||
|   end: 141, | ||||
|   start: 125, | ||||
|   type: "NonCodeNode", | ||||
|   value: " | ||||
|  // a comment | ||||
|    " | ||||
| }"#; | ||||
|  | ||||
| @ -4,7 +4,7 @@ use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity}; | ||||
|  | ||||
| use crate::executor::SourceRange; | ||||
|  | ||||
| #[derive(Error, Debug, Serialize, Deserialize, ts_rs::TS)] | ||||
| #[derive(Error, Debug, Serialize, Deserialize, ts_rs::TS, Clone)] | ||||
| #[ts(export)] | ||||
| #[serde(tag = "kind", rename_all = "snake_case")] | ||||
| pub enum KclError { | ||||
| @ -28,7 +28,7 @@ pub enum KclError { | ||||
|     Engine(KclErrorDetails), | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Serialize, Deserialize, ts_rs::TS)] | ||||
| #[derive(Debug, Serialize, Deserialize, ts_rs::TS, Clone)] | ||||
| #[ts(export)] | ||||
| pub struct KclErrorDetails { | ||||
|     #[serde(rename = "sourceRanges")] | ||||
| @ -78,6 +78,22 @@ impl KclError { | ||||
|             KclError::Engine(e) => e.source_ranges.clone(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Get the inner error message. | ||||
|     pub fn message(&self) -> &str { | ||||
|         match &self { | ||||
|             KclError::Syntax(e) => &e.message, | ||||
|             KclError::Semantic(e) => &e.message, | ||||
|             KclError::Type(e) => &e.message, | ||||
|             KclError::Unimplemented(e) => &e.message, | ||||
|             KclError::Unexpected(e) => &e.message, | ||||
|             KclError::ValueAlreadyDefined(e) => &e.message, | ||||
|             KclError::UndefinedValue(e) => &e.message, | ||||
|             KclError::InvalidExpression(e) => &e.message, | ||||
|             KclError::Engine(e) => &e.message, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn to_lsp_diagnostic(&self, code: &str) -> Diagnostic { | ||||
|         let (message, _, _) = self.get_message_line_column(code); | ||||
|         let source_ranges = self.source_ranges(); | ||||
|  | ||||
| @ -2,7 +2,7 @@ use std::{collections::HashMap, str::FromStr}; | ||||
|  | ||||
| use crate::{ | ||||
|     ast::types::{ | ||||
|         ArrayExpression, BinaryExpression, BinaryPart, BodyItem, CallExpression, ExpressionStatement, | ||||
|         ArrayExpression, BinaryExpression, BinaryPart, BodyItem, CallExpression, CommentStyle, ExpressionStatement, | ||||
|         FunctionExpression, Identifier, Literal, LiteralIdentifier, MemberExpression, MemberObject, NonCodeMeta, | ||||
|         NonCodeNode, NonCodeValue, ObjectExpression, ObjectKeyInfo, ObjectProperty, PipeExpression, PipeSubstitution, | ||||
|         Program, ReturnStatement, UnaryExpression, UnaryOperator, Value, VariableDeclaration, VariableDeclarator, | ||||
| @ -13,6 +13,8 @@ use crate::{ | ||||
|     token::{Token, TokenType}, | ||||
| }; | ||||
|  | ||||
| mod parser_impl; | ||||
|  | ||||
| pub const PIPE_SUBSTITUTION_OPERATOR: &str = "%"; | ||||
| pub const PIPE_OPERATOR: &str = "|>"; | ||||
|  | ||||
| @ -180,24 +182,7 @@ impl Parser { | ||||
|     } | ||||
|  | ||||
|     pub fn ast(&self) -> Result<Program, KclError> { | ||||
|         let body = self.make_body( | ||||
|             0, | ||||
|             vec![], | ||||
|             NonCodeMeta { | ||||
|                 non_code_nodes: HashMap::new(), | ||||
|                 start: None, | ||||
|             }, | ||||
|         )?; | ||||
|         let end = match self.get_token(body.last_index) { | ||||
|             Ok(token) => token.end, | ||||
|             Err(_) => self.tokens[self.tokens.len() - 1].end, | ||||
|         }; | ||||
|         Ok(Program { | ||||
|             start: 0, | ||||
|             end, | ||||
|             body: body.body, | ||||
|             non_code_meta: body.non_code_meta, | ||||
|         }) | ||||
|         parser_impl::run_parser(&mut self.tokens.as_slice()) | ||||
|     } | ||||
|  | ||||
|     fn make_identifier(&self, index: usize) -> Result<Identifier, KclError> { | ||||
| @ -209,7 +194,7 @@ impl Parser { | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     pub fn make_literal(&self, index: usize) -> Result<Literal, KclError> { | ||||
|     fn make_literal(&self, index: usize) -> Result<Literal, KclError> { | ||||
|         let token = self.get_token(index)?; | ||||
|         let value = if token.token_type == TokenType::Number { | ||||
|             if let Ok(value) = token.value.parse::<i64>() { | ||||
| @ -295,6 +280,11 @@ impl Parser { | ||||
|             )); | ||||
|         } | ||||
|  | ||||
|         let is_block_style = non_code_tokens | ||||
|             .first() | ||||
|             .map(|tok| matches!(tok.token_type, TokenType::BlockComment)) | ||||
|             .unwrap_or_default(); | ||||
|  | ||||
|         let full_string = non_code_tokens | ||||
|             .iter() | ||||
|             .map(|t| { | ||||
| @ -336,11 +326,32 @@ impl Parser { | ||||
|             value: if start_end_string.starts_with("\n\n") && is_new_line_comment { | ||||
|                 // Preserve if they want a whitespace line before the comment. | ||||
|                 // But let's just allow one. | ||||
|                 NonCodeValue::NewLineBlockComment { value: full_string } | ||||
|             } else if is_new_line_comment { | ||||
|                 NonCodeValue::BlockComment { value: full_string } | ||||
|                 NonCodeValue::NewLineBlockComment { | ||||
|                     value: full_string, | ||||
|                     style: if is_block_style { | ||||
|                         CommentStyle::Block | ||||
|                     } else { | ||||
|                 NonCodeValue::InlineComment { value: full_string } | ||||
|                         CommentStyle::Line | ||||
|                     }, | ||||
|                 } | ||||
|             } else if is_new_line_comment { | ||||
|                 NonCodeValue::BlockComment { | ||||
|                     value: full_string, | ||||
|                     style: if is_block_style { | ||||
|                         CommentStyle::Block | ||||
|                     } else { | ||||
|                         CommentStyle::Line | ||||
|                     }, | ||||
|                 } | ||||
|             } else { | ||||
|                 NonCodeValue::InlineComment { | ||||
|                     value: full_string, | ||||
|                     style: if is_block_style { | ||||
|                         CommentStyle::Block | ||||
|                     } else { | ||||
|                         CommentStyle::Line | ||||
|                     }, | ||||
|                 } | ||||
|             }, | ||||
|         }; | ||||
|         Ok((Some(node), end_index - 1)) | ||||
| @ -1064,7 +1075,7 @@ impl Parser { | ||||
|         let mut _non_code_meta: NonCodeMeta; | ||||
|         if let Some(node) = next_pipe.non_code_node { | ||||
|             _non_code_meta = non_code_meta; | ||||
|             _non_code_meta.non_code_nodes.insert(previous_values.len(), node); | ||||
|             _non_code_meta.insert(previous_values.len(), node); | ||||
|         } else { | ||||
|             _non_code_meta = non_code_meta; | ||||
|         } | ||||
| @ -1435,7 +1446,7 @@ impl Parser { | ||||
|         self.make_params(next_brace_or_comma_token.index, _previous_params) | ||||
|     } | ||||
|  | ||||
|     pub fn make_unary_expression(&self, index: usize) -> Result<UnaryExpressionResult, KclError> { | ||||
|     fn make_unary_expression(&self, index: usize) -> Result<UnaryExpressionResult, KclError> { | ||||
|         let current_token = self.get_token(index)?; | ||||
|         let next_token = self.next_meaningful_token(index, None)?; | ||||
|         if next_token.token.is_none() { | ||||
| @ -1633,7 +1644,7 @@ impl Parser { | ||||
|                 if previous_body.is_empty() { | ||||
|                     non_code_meta.start = next_token.non_code_node; | ||||
|                 } else { | ||||
|                     non_code_meta.non_code_nodes.insert(previous_body.len(), node.clone()); | ||||
|                     non_code_meta.insert(previous_body.len(), node.clone()); | ||||
|                 } | ||||
|             } | ||||
|             return self.make_body(next_token.index, previous_body, non_code_meta); | ||||
| @ -1641,14 +1652,14 @@ impl Parser { | ||||
|  | ||||
|         let next = self.next_meaningful_token(token_index, None)?; | ||||
|         if let Some(node) = &next.non_code_node { | ||||
|             non_code_meta.non_code_nodes.insert(previous_body.len(), node.clone()); | ||||
|             non_code_meta.insert(previous_body.len(), node.clone()); | ||||
|         } | ||||
|  | ||||
|         if token.token_type == TokenType::Keyword && VariableKind::from_str(&token.value).is_ok() { | ||||
|             let declaration = self.make_variable_declaration(token_index)?; | ||||
|             let next_thing = self.next_meaningful_token(declaration.last_index, None)?; | ||||
|             if let Some(node) = &next_thing.non_code_node { | ||||
|                 non_code_meta.non_code_nodes.insert(previous_body.len(), node.clone()); | ||||
|                 non_code_meta.insert(previous_body.len(), node.clone()); | ||||
|             } | ||||
|             let mut _previous_body = previous_body; | ||||
|             _previous_body.push(BodyItem::VariableDeclaration(VariableDeclaration { | ||||
| @ -1669,7 +1680,7 @@ impl Parser { | ||||
|             let statement = self.make_return_statement(token_index)?; | ||||
|             let next_thing = self.next_meaningful_token(statement.last_index, None)?; | ||||
|             if let Some(node) = &next_thing.non_code_node { | ||||
|                 non_code_meta.non_code_nodes.insert(previous_body.len(), node.clone()); | ||||
|                 non_code_meta.insert(previous_body.len(), node.clone()); | ||||
|             } | ||||
|             let mut _previous_body = previous_body; | ||||
|             _previous_body.push(BodyItem::ReturnStatement(ReturnStatement { | ||||
| @ -1693,7 +1704,7 @@ impl Parser { | ||||
|                 let expression = self.make_expression_statement(token_index)?; | ||||
|                 let next_thing = self.next_meaningful_token(expression.last_index, None)?; | ||||
|                 if let Some(node) = &next_thing.non_code_node { | ||||
|                     non_code_meta.non_code_nodes.insert(previous_body.len(), node.clone()); | ||||
|                     non_code_meta.insert(previous_body.len(), node.clone()); | ||||
|                 } | ||||
|                 let mut _previous_body = previous_body; | ||||
|                 _previous_body.push(BodyItem::ExpressionStatement(ExpressionStatement { | ||||
| @ -1716,7 +1727,7 @@ impl Parser { | ||||
|                 && next_thing_token.token_type == TokenType::Operator | ||||
|             { | ||||
|                 if let Some(node) = &next_thing.non_code_node { | ||||
|                     non_code_meta.non_code_nodes.insert(previous_body.len(), node.clone()); | ||||
|                     non_code_meta.insert(previous_body.len(), node.clone()); | ||||
|                 } | ||||
|                 let expression = self.make_expression_statement(token_index)?; | ||||
|                 let mut _previous_body = previous_body; | ||||
| @ -1913,6 +1924,7 @@ const key = 'c'"#, | ||||
|                 end: 60, | ||||
|                 value: NonCodeValue::BlockComment { | ||||
|                     value: "this is a comment".to_string(), | ||||
|                     style: CommentStyle::Line, | ||||
|                 }, | ||||
|             }), | ||||
|             31, | ||||
| @ -1966,6 +1978,35 @@ const key = 'c'"#, | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_math_parse() { | ||||
|         let tokens = crate::token::lexer(r#"5 + "a""#); | ||||
|         let actual = Parser::new(tokens).ast().unwrap().body; | ||||
|         let expr = BinaryExpression { | ||||
|             start: 0, | ||||
|             end: 7, | ||||
|             operator: BinaryOperator::Add, | ||||
|             left: BinaryPart::Literal(Box::new(Literal { | ||||
|                 start: 0, | ||||
|                 end: 1, | ||||
|                 value: serde_json::Value::Number(serde_json::Number::from(5)), | ||||
|                 raw: "5".to_owned(), | ||||
|             })), | ||||
|             right: BinaryPart::Literal(Box::new(Literal { | ||||
|                 start: 4, | ||||
|                 end: 7, | ||||
|                 value: serde_json::Value::String("a".to_owned()), | ||||
|                 raw: r#""a""#.to_owned(), | ||||
|             })), | ||||
|         }; | ||||
|         let expected = vec![BodyItem::ExpressionStatement(ExpressionStatement { | ||||
|             start: 0, | ||||
|             end: 7, | ||||
|             expression: Value::BinaryExpression(Box::new(expr)), | ||||
|         })]; | ||||
|         assert_eq!(expected, actual); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_is_code_token() { | ||||
|         let tokens = [ | ||||
| @ -2812,10 +2853,6 @@ z(-[["#, | ||||
|         let parser = Parser::new(tokens); | ||||
|         let result = parser.ast(); | ||||
|         assert!(result.is_err()); | ||||
|         assert_eq!( | ||||
|             result.err().unwrap().to_string(), | ||||
|             r#"syntax: KclErrorDetails { source_ranges: [SourceRange([1, 2])], message: "missing a closing brace for the function call" }"# | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
| @ -2831,7 +2868,7 @@ z(-[["#, | ||||
|         // https://github.com/KittyCAD/modeling-app/issues/696 | ||||
|         assert_eq!( | ||||
|             result.err().unwrap().to_string(), | ||||
|             r#"semantic: KclErrorDetails { source_ranges: [], message: "file is empty" }"# | ||||
|             r#"syntax: KclErrorDetails { source_ranges: [], message: "file is empty" }"# | ||||
|         ); | ||||
|     } | ||||
|  | ||||
| @ -2845,7 +2882,7 @@ z(-[["#, | ||||
|         // https://github.com/KittyCAD/modeling-app/issues/696 | ||||
|         assert_eq!( | ||||
|             result.err().unwrap().to_string(), | ||||
|             r#"semantic: KclErrorDetails { source_ranges: [], message: "file is empty" }"# | ||||
|             r#"syntax: KclErrorDetails { source_ranges: [], message: "file is empty" }"# | ||||
|         ); | ||||
|     } | ||||
|  | ||||
| @ -2863,7 +2900,7 @@ e | ||||
|             .err() | ||||
|             .unwrap() | ||||
|             .to_string() | ||||
|             .contains("expected to be started on a identifier or literal")); | ||||
|             .contains("expected whitespace, found ')' which is brace")); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
| @ -2872,7 +2909,11 @@ e | ||||
|         let parser = Parser::new(tokens); | ||||
|         let result = parser.ast(); | ||||
|         assert!(result.is_err()); | ||||
|         assert!(result.err().unwrap().to_string().contains("expected another token")); | ||||
|         assert!(result | ||||
|             .err() | ||||
|             .unwrap() | ||||
|             .to_string() | ||||
|             .contains("expected whitespace, found ')' which is brace")); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
| @ -2884,11 +2925,7 @@ e | ||||
|         let parser = Parser::new(tokens); | ||||
|         let result = parser.ast(); | ||||
|         assert!(result.is_err()); | ||||
|         assert!(result | ||||
|             .err() | ||||
|             .unwrap() | ||||
|             .to_string() | ||||
|             .contains("unexpected end of expression")); | ||||
|         assert!(result.err().unwrap().to_string().contains("Unexpected token")); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
| @ -3022,7 +3059,9 @@ e | ||||
|  | ||||
|     #[test] | ||||
|     fn test_error_stdlib_in_fn_name() { | ||||
|         let some_program_string = r#"fn cos = () {}"#; | ||||
|         let some_program_string = r#"fn cos = () => { | ||||
|             return 1 | ||||
|         }"#; | ||||
|         let tokens = crate::token::lexer(some_program_string); | ||||
|         let parser = Parser::new(tokens); | ||||
|         let result = parser.ast(); | ||||
| @ -3123,9 +3162,12 @@ thing(false) | ||||
|         let parser = Parser::new(tokens); | ||||
|         let result = parser.ast(); | ||||
|         assert!(result.is_err()); | ||||
|         // TODO: https://github.com/KittyCAD/modeling-app/issues/784 | ||||
|         // Improve this error message. | ||||
|         // It should say that the compiler is expecting a function expression on the RHS. | ||||
|         assert_eq!( | ||||
|             result.err().unwrap().to_string(), | ||||
|             r#"syntax: KclErrorDetails { source_ranges: [SourceRange([0, 2])], message: "Expected a `let` variable kind, found: `fn`" }"# | ||||
|             r#"syntax: KclErrorDetails { source_ranges: [SourceRange([11, 18])], message: "Unexpected token" }"# | ||||
|         ); | ||||
|     } | ||||
|  | ||||
| @ -3163,15 +3205,6 @@ let other_thing = 2 * cos(3)"#; | ||||
|         parser.ast().unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_parse_pipes_on_pipes() { | ||||
|         let code = include_str!("../../tests/executor/inputs/pipes_on_pipes.kcl"); | ||||
|  | ||||
|         let tokens = crate::token::lexer(code); | ||||
|         let parser = Parser::new(tokens); | ||||
|         parser.ast().unwrap(); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_negative_arguments() { | ||||
|         let some_program_string = r#"fn box = (p, h, l, w) => { | ||||
|  | ||||
							
								
								
									
										1967
									
								
								src/wasm-lib/kcl/src/parser/parser_impl.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1967
									
								
								src/wasm-lib/kcl/src/parser/parser_impl.rs
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										107
									
								
								src/wasm-lib/kcl/src/parser/parser_impl/error.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								src/wasm-lib/kcl/src/parser/parser_impl/error.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,107 @@ | ||||
| use winnow::error::{ErrorKind, ParseError, StrContext}; | ||||
|  | ||||
| use crate::{ | ||||
|     errors::{KclError, KclErrorDetails}, | ||||
|     token::Token, | ||||
| }; | ||||
|  | ||||
| /// Accumulate context while backtracking errors | ||||
| /// Very similar to [`winnow::error::ContextError`] type, | ||||
| /// but the 'cause' field is always a [`KclError`], | ||||
| /// instead of a dynamic [`std::error::Error`] trait object. | ||||
| #[derive(Debug, Clone)] | ||||
| pub struct ContextError<C = StrContext> { | ||||
|     pub context: Vec<C>, | ||||
|     pub cause: Option<KclError>, | ||||
| } | ||||
|  | ||||
| impl From<ParseError<&[Token], ContextError>> for KclError { | ||||
|     fn from(err: ParseError<&[Token], ContextError>) -> Self { | ||||
|         let Some(last_token) = err.input().last() else { | ||||
|             return KclError::Syntax(KclErrorDetails { | ||||
|                 source_ranges: Default::default(), | ||||
|                 message: "file is empty".to_owned(), | ||||
|             }); | ||||
|         }; | ||||
|  | ||||
|         let (input, offset, err) = (err.input().to_vec(), err.offset(), err.into_inner()); | ||||
|  | ||||
|         if let Some(e) = err.cause { | ||||
|             return e; | ||||
|         } | ||||
|  | ||||
|         // See docs on `offset`. | ||||
|         if offset >= input.len() { | ||||
|             let context = err.context.first(); | ||||
|             return KclError::Syntax(KclErrorDetails { | ||||
|                 source_ranges: last_token.as_source_ranges(), | ||||
|                 message: match context { | ||||
|                     Some(what) => format!("Unexpected end of file. The compiler {what}"), | ||||
|                     None => "Unexpected end of file while still parsing".to_owned(), | ||||
|                 }, | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         let bad_token = &input[offset]; | ||||
|         // TODO: Add the Winnow parser context to the error. | ||||
|         // See https://github.com/KittyCAD/modeling-app/issues/784 | ||||
|         KclError::Syntax(KclErrorDetails { | ||||
|             source_ranges: bad_token.as_source_ranges(), | ||||
|             message: "Unexpected token".to_owned(), | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<C> From<KclError> for ContextError<C> { | ||||
|     fn from(e: KclError) -> Self { | ||||
|         Self { | ||||
|             context: Default::default(), | ||||
|             cause: Some(e), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<C> std::default::Default for ContextError<C> { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             context: Default::default(), | ||||
|             cause: None, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<I, C> winnow::error::ParserError<I> for ContextError<C> { | ||||
|     #[inline] | ||||
|     fn from_error_kind(_input: &I, _kind: ErrorKind) -> Self { | ||||
|         Self::default() | ||||
|     } | ||||
|  | ||||
|     #[inline] | ||||
|     fn append(self, _input: &I, _kind: ErrorKind) -> Self { | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     #[inline] | ||||
|     fn or(self, other: Self) -> Self { | ||||
|         other | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<C, I> winnow::error::AddContext<I, C> for ContextError<C> { | ||||
|     #[inline] | ||||
|     fn add_context(mut self, _input: &I, ctx: C) -> Self { | ||||
|         self.context.push(ctx); | ||||
|         self | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<C, I> winnow::error::FromExternalError<I, KclError> for ContextError<C> { | ||||
|     #[inline] | ||||
|     fn from_external_error(_input: &I, _kind: ErrorKind, e: KclError) -> Self { | ||||
|         let mut err = Self::default(); | ||||
|         { | ||||
|             err.cause = Some(e); | ||||
|         } | ||||
|         err | ||||
|     } | ||||
| } | ||||
| @ -6,6 +6,8 @@ use schemars::JsonSchema; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use tower_lsp::lsp_types::SemanticTokenType; | ||||
|  | ||||
| use crate::{ast::types::VariableKind, executor::SourceRange}; | ||||
|  | ||||
| mod tokeniser; | ||||
|  | ||||
| /// The types of tokens. | ||||
| @ -142,15 +144,39 @@ impl Token { | ||||
|             TokenType::Whitespace | TokenType::LineComment | TokenType::BlockComment | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     pub fn as_source_range(&self) -> SourceRange { | ||||
|         SourceRange([self.start, self.end]) | ||||
|     } | ||||
|  | ||||
| impl From<Token> for crate::executor::SourceRange { | ||||
|     pub fn as_source_ranges(&self) -> Vec<SourceRange> { | ||||
|         vec![self.as_source_range()] | ||||
|     } | ||||
|  | ||||
|     /// Is this token the beginning of a variable/function declaration? | ||||
|     /// If so, what kind? | ||||
|     /// If not, returns None. | ||||
|     pub fn declaration_keyword(&self) -> Option<VariableKind> { | ||||
|         if !matches!(self.token_type, TokenType::Keyword) { | ||||
|             return None; | ||||
|         } | ||||
|         Some(match self.value.as_str() { | ||||
|             "var" => VariableKind::Var, | ||||
|             "let" => VariableKind::Let, | ||||
|             "fn" => VariableKind::Fn, | ||||
|             "const" => VariableKind::Const, | ||||
|             _ => return None, | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<Token> for SourceRange { | ||||
|     fn from(token: Token) -> Self { | ||||
|         Self([token.start, token.end]) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<&Token> for crate::executor::SourceRange { | ||||
| impl From<&Token> for SourceRange { | ||||
|     fn from(token: &Token) -> Self { | ||||
|         Self([token.start, token.end]) | ||||
|     } | ||||
|  | ||||
							
								
								
									
										12
									
								
								src/wasm-lib/tests/executor/inputs/cube.kcl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/wasm-lib/tests/executor/inputs/cube.kcl
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| fn cube = (pos, scale) => { | ||||
|   const sg = startSketchAt(pos) | ||||
|     |> line([0, scale], %) | ||||
|     |> line([scale, 0], %) | ||||
|     |> line([0, -scale], %) | ||||
|  | ||||
|   return sg | ||||
| } | ||||
|  | ||||
| const b1 = cube([0,0], 10) | ||||
| const pt1 = b1[0] | ||||
| show(b1) | ||||
| @ -87,7 +87,7 @@ const fnBox = box(3, 6, 10) | ||||
| show(fnBox)"#; | ||||
|  | ||||
|     let result = execute_and_snapshot(code).await.unwrap(); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/function_sketch.png", &result, 1.0); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/function_sketch.png", &result, 0.999); | ||||
| } | ||||
|  | ||||
| #[tokio::test(flavor = "multi_thread")] | ||||
| @ -107,7 +107,11 @@ async fn serial_test_execute_with_function_sketch_with_position() { | ||||
| show(box([0,0], 3, 6, 10))"#; | ||||
|  | ||||
|     let result = execute_and_snapshot(code).await.unwrap(); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/function_sketch_with_position.png", &result, 1.0); | ||||
|     twenty_twenty::assert_image( | ||||
|         "tests/executor/outputs/function_sketch_with_position.png", | ||||
|         &result, | ||||
|         0.999, | ||||
|     ); | ||||
| } | ||||
|  | ||||
| #[tokio::test(flavor = "multi_thread")] | ||||
| @ -125,7 +129,7 @@ async fn serial_test_execute_with_angled_line() { | ||||
| show(part001)"#; | ||||
|  | ||||
|     let result = execute_and_snapshot(code).await.unwrap(); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/angled_line.png", &result, 1.0); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/angled_line.png", &result, 0.999); | ||||
| } | ||||
|  | ||||
| #[tokio::test(flavor = "multi_thread")] | ||||
| @ -152,7 +156,7 @@ const bracket = startSketchOn('XY') | ||||
| show(bracket)"#; | ||||
|  | ||||
|     let result = execute_and_snapshot(code).await.unwrap(); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/parametric.png", &result, 1.0); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/parametric.png", &result, 0.999); | ||||
| } | ||||
|  | ||||
| #[tokio::test(flavor = "multi_thread")] | ||||
| @ -187,7 +191,7 @@ const bracket = startSketchAt([0, 0]) | ||||
| show(bracket)"#; | ||||
|  | ||||
|     let result = execute_and_snapshot(code).await.unwrap(); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/parametric_with_tan_arc.png", &result, 1.0); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/parametric_with_tan_arc.png", &result, 0.999); | ||||
| } | ||||
|  | ||||
| #[tokio::test(flavor = "multi_thread")] | ||||
| @ -215,7 +219,7 @@ async fn serial_test_execute_pipes_on_pipes() { | ||||
|     let code = include_str!("inputs/pipes_on_pipes.kcl"); | ||||
|  | ||||
|     let result = execute_and_snapshot(code).await.unwrap(); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/pipes_on_pipes.png", &result, 1.0); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/pipes_on_pipes.png", &result, 0.999); | ||||
| } | ||||
|  | ||||
| #[tokio::test(flavor = "multi_thread")] | ||||
| @ -223,7 +227,7 @@ async fn serial_test_execute_kittycad_svg() { | ||||
|     let code = include_str!("inputs/kittycad_svg.kcl"); | ||||
|  | ||||
|     let result = execute_and_snapshot(code).await.unwrap(); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/kittycad_svg.png", &result, 1.0); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/kittycad_svg.png", &result, 0.999); | ||||
| } | ||||
|  | ||||
| #[tokio::test(flavor = "multi_thread")] | ||||
| @ -270,7 +274,7 @@ const body = startSketchOn('XY') | ||||
| show(body)"#; | ||||
|  | ||||
|     let result = execute_and_snapshot(code).await.unwrap(); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/close_arc.png", &result, 1.0); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/close_arc.png", &result, 0.999); | ||||
| } | ||||
|  | ||||
| #[tokio::test(flavor = "multi_thread")] | ||||
| @ -296,7 +300,7 @@ let thing = box(-12, -15, 10) | ||||
| box(-20, -5, 10)"#; | ||||
|  | ||||
|     let result = execute_and_snapshot(code).await.unwrap(); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/negative_args.png", &result, 1.0); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/negative_args.png", &result, 0.999); | ||||
| } | ||||
|  | ||||
| #[tokio::test(flavor = "multi_thread")] | ||||
| @ -309,7 +313,7 @@ async fn test_basic_tangental_arc() { | ||||
| "#; | ||||
|  | ||||
|     let result = execute_and_snapshot(code).await.unwrap(); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/tangental_arc.png", &result, 1.0); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/tangental_arc.png", &result, 0.999); | ||||
| } | ||||
|  | ||||
| #[tokio::test(flavor = "multi_thread")] | ||||
| @ -322,7 +326,7 @@ async fn test_basic_tangental_arc_with_point() { | ||||
| "#; | ||||
|  | ||||
|     let result = execute_and_snapshot(code).await.unwrap(); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/tangental_arc_with_point.png", &result, 1.0); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/tangental_arc_with_point.png", &result, 0.999); | ||||
| } | ||||
|  | ||||
| #[tokio::test(flavor = "multi_thread")] | ||||
| @ -335,7 +339,7 @@ async fn test_basic_tangental_arc_to() { | ||||
| "#; | ||||
|  | ||||
|     let result = execute_and_snapshot(code).await.unwrap(); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/tangental_arc_to.png", &result, 1.0); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/tangental_arc_to.png", &result, 0.999); | ||||
| } | ||||
|  | ||||
| #[tokio::test(flavor = "multi_thread")] | ||||
| @ -362,7 +366,11 @@ let thing = box(-12, -15, 10, 'yz') | ||||
| box(-20, -5, 10, 'xy')"#; | ||||
|  | ||||
|     let result = execute_and_snapshot(code).await.unwrap(); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/different_planes_same_drawing.png", &result, 1.0); | ||||
|     twenty_twenty::assert_image( | ||||
|         "tests/executor/outputs/different_planes_same_drawing.png", | ||||
|         &result, | ||||
|         0.999, | ||||
|     ); | ||||
| } | ||||
|  | ||||
| #[tokio::test(flavor = "multi_thread")] | ||||
| @ -421,5 +429,5 @@ const part004 = startSketchOn('YZ') | ||||
| "#; | ||||
|  | ||||
|     let result = execute_and_snapshot(code).await.unwrap(); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/lots_of_planes.png", &result, 1.0); | ||||
|     twenty_twenty::assert_image("tests/executor/outputs/lots_of_planes.png", &result, 0.999); | ||||
| } | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	