diff --git a/src/wasm-lib/kcl/src/execution/annotations.rs b/src/wasm-lib/kcl/src/execution/annotations.rs new file mode 100644 index 000000000..c0fbb5575 --- /dev/null +++ b/src/wasm-lib/kcl/src/execution/annotations.rs @@ -0,0 +1,73 @@ +//! Data on available annotations. + +use super::kcl_value::{UnitAngle, UnitLen}; +use crate::{ + errors::KclErrorDetails, + parsing::ast::types::{Expr, Node, NonCodeValue, ObjectProperty}, + KclError, SourceRange, +}; + +pub(super) const SETTINGS: &str = "settings"; +pub(super) const SETTINGS_UNIT_LENGTH: &str = "defaultLengthUnit"; +pub(super) const SETTINGS_UNIT_ANGLE: &str = "defaultAngleUnit"; + +pub(super) fn expect_properties<'a>( + for_key: &'static str, + annotation: &'a NonCodeValue, + source_range: SourceRange, +) -> Result<&'a [Node], KclError> { + match annotation { + NonCodeValue::Annotation { name, properties } => { + assert_eq!(name.name, for_key); + Ok(&**properties.as_ref().ok_or_else(|| { + KclError::Semantic(KclErrorDetails { + message: format!("Empty `{for_key}` annotation"), + source_ranges: vec![source_range], + }) + })?) + } + _ => unreachable!(), + } +} + +pub(super) fn expect_ident(expr: &Expr) -> Result<&str, KclError> { + match expr { + Expr::Identifier(id) => Ok(&id.name), + e => Err(KclError::Semantic(KclErrorDetails { + message: "Unexpected settings value, expected a simple name, e.g., `mm`".to_owned(), + source_ranges: vec![e.into()], + })), + } +} + +impl UnitLen { + pub(super) fn from_str(s: &str, source_range: SourceRange) -> Result { + match s { + "mm" => Ok(UnitLen::Mm), + "cm" => Ok(UnitLen::Cm), + "m" => Ok(UnitLen::M), + "inch" | "in" => Ok(UnitLen::Inches), + "ft" => Ok(UnitLen::Feet), + "yd" => Ok(UnitLen::Yards), + value => Err(KclError::Semantic(KclErrorDetails { + message: format!( + "Unexpected settings value: `{value}`; expected one of `mm`, `cm`, `m`, `inch`, `ft`, `yd`" + ), + source_ranges: vec![source_range], + })), + } + } +} + +impl UnitAngle { + pub(super) fn from_str(s: &str, source_range: SourceRange) -> Result { + match s { + "deg" => Ok(UnitAngle::Degrees), + "rad" => Ok(UnitAngle::Radians), + value => Err(KclError::Semantic(KclErrorDetails { + message: format!("Unexpected settings value: `{value}`; expected one of `deg`, `rad`"), + source_ranges: vec![source_range], + })), + } + } +} diff --git a/src/wasm-lib/kcl/src/execution/kcl_value.rs b/src/wasm-lib/kcl/src/execution/kcl_value.rs index 756dffb29..75871a6d3 100644 --- a/src/wasm-lib/kcl/src/execution/kcl_value.rs +++ b/src/wasm-lib/kcl/src/execution/kcl_value.rs @@ -8,7 +8,10 @@ use crate::{ errors::KclErrorDetails, exec::{ProgramMemory, Sketch}, execution::{Face, ImportedGeometry, MemoryFunction, Metadata, Plane, SketchSet, Solid, SolidSet, TagIdentifier}, - parsing::ast::types::{FunctionExpression, KclNone, LiteralValue, TagDeclarator, TagNode}, + parsing::{ + ast::types::{FunctionExpression, KclNone, LiteralValue, TagDeclarator, TagNode}, + token::NumericSuffix, + }, std::{args::Arg, FnAsArg}, ExecState, ExecutorContext, KclError, ModuleId, SourceRange, }; @@ -561,3 +564,52 @@ impl KclValue { } } } + +// TODO called UnitLen so as not to clash with UnitLength in settings) +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema, Eq)] +#[ts(export)] +#[serde(tag = "type")] +pub enum UnitLen { + Mm, + Cm, + M, + Inches, + Feet, + Yards, +} + +impl TryFrom for UnitLen { + type Error = (); + + fn try_from(suffix: NumericSuffix) -> std::result::Result { + match suffix { + NumericSuffix::Mm => Ok(Self::Mm), + NumericSuffix::Cm => Ok(Self::Cm), + NumericSuffix::M => Ok(Self::M), + NumericSuffix::Inch => Ok(Self::Inches), + NumericSuffix::Ft => Ok(Self::Feet), + NumericSuffix::Yd => Ok(Self::Yards), + _ => Err(()), + } + } +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema, Eq)] +#[ts(export)] +#[serde(tag = "type")] +pub enum UnitAngle { + Degrees, + Radians, +} + +impl TryFrom for UnitAngle { + type Error = (); + + fn try_from(suffix: NumericSuffix) -> std::result::Result { + match suffix { + NumericSuffix::Deg => Ok(Self::Degrees), + NumericSuffix::Rad => Ok(Self::Radians), + _ => Err(()), + } + } +} diff --git a/src/wasm-lib/kcl/src/execution/mod.rs b/src/wasm-lib/kcl/src/execution/mod.rs index b1b3c12b4..c48a71bbe 100644 --- a/src/wasm-lib/kcl/src/execution/mod.rs +++ b/src/wasm-lib/kcl/src/execution/mod.rs @@ -24,6 +24,7 @@ pub use function_param::FunctionParam; pub use kcl_value::{KclObjectFields, KclValue}; use uuid::Uuid; +mod annotations; pub(crate) mod cache; mod cad_op; mod exec_ast; @@ -36,8 +37,8 @@ use crate::{ execution::cache::{CacheInformation, CacheResult}, fs::{FileManager, FileSystem}, parsing::ast::types::{ - BodyItem, Expr, FunctionExpression, ImportSelector, ItemVisibility, Node, NodeRef, Program as AstProgram, - TagDeclarator, TagNode, + BodyItem, Expr, FunctionExpression, ImportSelector, ItemVisibility, Node, NodeRef, NonCodeValue, + Program as AstProgram, TagDeclarator, TagNode, }, settings::types::UnitLength, source_range::{ModuleId, SourceRange}, @@ -88,6 +89,8 @@ pub struct ModuleState { /// Operations that have been performed in execution order, for display in /// the Feature Tree. pub operations: Vec, + /// Settings specified from annotations. + pub settings: MetaSettings, } impl Default for ExecState { @@ -186,6 +189,56 @@ impl GlobalState { } } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct MetaSettings { + pub default_length_units: kcl_value::UnitLen, + pub default_angle_units: kcl_value::UnitAngle, +} + +impl Default for MetaSettings { + fn default() -> Self { + MetaSettings { + default_length_units: kcl_value::UnitLen::Mm, + default_angle_units: kcl_value::UnitAngle::Degrees, + } + } +} + +impl MetaSettings { + fn update_from_annotation(&mut self, annotation: &NonCodeValue, source_range: SourceRange) -> Result<(), KclError> { + let properties = annotations::expect_properties(annotations::SETTINGS, annotation, source_range)?; + + for p in properties { + match &*p.inner.key.name { + annotations::SETTINGS_UNIT_LENGTH => { + let value = annotations::expect_ident(&p.inner.value)?; + let value = kcl_value::UnitLen::from_str(value, source_range)?; + self.default_length_units = value; + } + annotations::SETTINGS_UNIT_ANGLE => { + let value = annotations::expect_ident(&p.inner.value)?; + let value = kcl_value::UnitAngle::from_str(value, source_range)?; + self.default_angle_units = value; + } + name => { + return Err(KclError::Semantic(KclErrorDetails { + message: format!( + "Unexpected settings key: `{name}`; expected one of `{}`, `{}`", + annotations::SETTINGS_UNIT_LENGTH, + annotations::SETTINGS_UNIT_ANGLE + ), + source_ranges: vec![source_range], + })) + } + } + } + + Ok(()) + } +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)] #[ts(export)] #[serde(rename_all = "camelCase")] @@ -2020,6 +2073,22 @@ impl ExecutorContext { exec_state: &mut ExecState, body_type: BodyType, ) -> Result, KclError> { + if let Some((annotation, source_range)) = program + .non_code_meta + .start_nodes + .iter() + .filter_map(|n| { + n.annotation(annotations::SETTINGS) + .map(|result| (result, n.as_source_range())) + }) + .next() + { + exec_state + .mod_local + .settings + .update_from_annotation(annotation, source_range)?; + } + let mut last_expr = None; // Iterate over the body of the program. for statement in &program.body { diff --git a/src/wasm-lib/kcl/src/parsing/ast/types/mod.rs b/src/wasm-lib/kcl/src/parsing/ast/types/mod.rs index f37fbf84c..0e641cd14 100644 --- a/src/wasm-lib/kcl/src/parsing/ast/types/mod.rs +++ b/src/wasm-lib/kcl/src/parsing/ast/types/mod.rs @@ -1000,52 +1000,22 @@ pub struct NonCodeNode { pub digest: Option, } -impl Node { - pub fn format(&self, indentation: &str) -> String { - match &self.value { - NonCodeValue::InlineComment { - value, - style: CommentStyle::Line, - } => format!(" // {}\n", value), - NonCodeValue::InlineComment { - value, - style: CommentStyle::Block, - } => format!(" /* {} */", value), - NonCodeValue::BlockComment { value, style } => match style { - CommentStyle::Block => format!("{}/* {} */", indentation, value), - CommentStyle::Line => { - if value.trim().is_empty() { - format!("{}//\n", indentation) - } else { - format!("{}// {}\n", indentation, value.trim()) - } - } - }, - NonCodeValue::NewLineBlockComment { value, style } => { - let add_start_new_line = if self.start == 0 { "" } else { "\n\n" }; - match style { - CommentStyle::Block => format!("{}{}/* {} */\n", add_start_new_line, indentation, value), - CommentStyle::Line => { - if value.trim().is_empty() { - format!("{}{}//\n", add_start_new_line, indentation) - } else { - format!("{}{}// {}\n", add_start_new_line, indentation, value.trim()) - } - } - } - } - NonCodeValue::NewLine => "\n\n".to_string(), - } - } -} - impl NonCodeNode { + #[cfg(test)] pub fn value(&self) -> String { match &self.value { NonCodeValue::InlineComment { value, style: _ } => value.clone(), NonCodeValue::BlockComment { value, style: _ } => value.clone(), NonCodeValue::NewLineBlockComment { value, style: _ } => value.clone(), NonCodeValue::NewLine => "\n\n".to_string(), + NonCodeValue::Annotation { name, .. } => name.name.clone(), + } + } + + pub fn annotation(&self, expected_name: &str) -> Option<&NonCodeValue> { + match &self.value { + a @ NonCodeValue::Annotation { name, .. } if name.name == expected_name => Some(a), + _ => None, } } } @@ -1063,6 +1033,7 @@ pub enum CommentStyle { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)] #[ts(export)] #[serde(tag = "type", rename_all = "camelCase")] +#[allow(clippy::large_enum_variant)] pub enum NonCodeValue { /// An inline comment. /// Here are examples: @@ -1095,6 +1066,10 @@ pub enum NonCodeValue { // A new line like `\n\n` NOT a new line like `\n`. // This is also not a comment. NewLine, + Annotation { + name: Node, + properties: Option>>, + }, } #[derive(Debug, Default, Clone, Serialize, PartialEq, ts_rs::TS, JsonSchema)] diff --git a/src/wasm-lib/kcl/src/parsing/parser.rs b/src/wasm-lib/kcl/src/parsing/parser.rs index 5b66b7abb..93894b501 100644 --- a/src/wasm-lib/kcl/src/parsing/parser.rs +++ b/src/wasm-lib/kcl/src/parsing/parser.rs @@ -283,38 +283,86 @@ fn non_code_node(i: &mut TokenSlice) -> PResult> { alt((non_code_node_leading_whitespace, non_code_node_no_leading_whitespace)).parse_next(i) } +fn annotation(i: &mut TokenSlice) -> PResult> { + let at = at_sign.parse_next(i)?; + let name = binding_name.parse_next(i)?; + let mut end = name.end; + + let properties = if peek(open_paren).parse_next(i).is_ok() { + open_paren(i)?; + ignore_whitespace(i); + let properties: Vec<_> = separated( + 0.., + separated_pair( + terminated(identifier, opt(whitespace)), + terminated(one_of((TokenType::Operator, "=")), opt(whitespace)), + expression, + ) + .map(|(key, value)| Node { + start: key.start, + end: value.end(), + module_id: key.module_id, + inner: ObjectProperty { + key, + value, + digest: None, + }, + }), + comma_sep, + ) + .parse_next(i)?; + ignore_trailing_comma(i); + ignore_whitespace(i); + end = close_paren(i)?.end; + Some(properties) + } else { + None + }; + + let value = NonCodeValue::Annotation { name, properties }; + Ok(Node::new( + NonCodeNode { value, digest: None }, + at.start, + end, + at.module_id, + )) +} + // Matches remaining three cases of NonCodeValue fn non_code_node_no_leading_whitespace(i: &mut TokenSlice) -> PResult> { - any.verify_map(|token: Token| { - if token.is_code_token() { - None - } else { - let value = match token.token_type { - TokenType::Whitespace if token.value.contains("\n\n") => NonCodeValue::NewLine, - TokenType::LineComment => NonCodeValue::BlockComment { - value: token.value.trim_start_matches("//").trim().to_owned(), - style: CommentStyle::Line, - }, - TokenType::BlockComment => NonCodeValue::BlockComment { - style: CommentStyle::Block, - value: token - .value - .trim_start_matches("/*") - .trim_end_matches("*/") - .trim() - .to_owned(), - }, - _ => return None, - }; - Some(Node::new( - NonCodeNode { value, digest: None }, - token.start, - token.end, - token.module_id, - )) - } - }) - .context(expected("Non-code token (comments or whitespace)")) + alt(( + annotation, + any.verify_map(|token: Token| { + if token.is_code_token() { + None + } else { + let value = match token.token_type { + TokenType::Whitespace if token.value.contains("\n\n") => NonCodeValue::NewLine, + TokenType::LineComment => NonCodeValue::BlockComment { + value: token.value.trim_start_matches("//").trim().to_owned(), + style: CommentStyle::Line, + }, + TokenType::BlockComment => NonCodeValue::BlockComment { + style: CommentStyle::Block, + value: token + .value + .trim_start_matches("/*") + .trim_end_matches("*/") + .trim() + .to_owned(), + }, + _ => return None, + }; + Some(Node::new( + NonCodeNode { value, digest: None }, + token.start, + token.end, + token.module_id, + )) + } + }) + .context(expected("Non-code token (comments or whitespace)")), + )) .parse_next(i) } @@ -1191,6 +1239,7 @@ fn noncode_just_after_code(i: &mut TokenSlice) -> PResult> { x @ NonCodeValue::InlineComment { .. } => x, x @ NonCodeValue::NewLineBlockComment { .. } => x, x @ NonCodeValue::NewLine => x, + x @ NonCodeValue::Annotation { .. } => x, }; Node::new( NonCodeNode { value, ..nc.inner }, @@ -1211,6 +1260,7 @@ fn noncode_just_after_code(i: &mut TokenSlice) -> PResult> { x @ NonCodeValue::InlineComment { .. } => x, x @ NonCodeValue::NewLineBlockComment { .. } => x, x @ NonCodeValue::NewLine => x, + x @ NonCodeValue::Annotation { .. } => x, }; Node::new(NonCodeNode { value, ..nc.inner }, nc.start, nc.end, nc.module_id) } @@ -1250,7 +1300,7 @@ fn body_items_within_function(i: &mut TokenSlice) -> PResult { (import_stmt.map(BodyItem::ImportStatement), opt(noncode_just_after_code)).map(WithinFunction::BodyItem), Token { ref value, .. } if value == "return" => (return_stmt.map(BodyItem::ReturnStatement), opt(noncode_just_after_code)).map(WithinFunction::BodyItem), - token if !token.is_code_token() => { + token if !token.is_code_token() || token.token_type == TokenType::At => { non_code_node.map(WithinFunction::NonCode) }, _ => @@ -2267,9 +2317,8 @@ fn question_mark(i: &mut TokenSlice) -> PResult<()> { Ok(()) } -fn at_sign(i: &mut TokenSlice) -> PResult<()> { - TokenType::At.parse_from(i)?; - Ok(()) +fn at_sign(i: &mut TokenSlice) -> PResult { + TokenType::At.parse_from(i) } fn fun(i: &mut TokenSlice) -> PResult { @@ -3626,6 +3675,22 @@ height = [obj["a"] -1, 0]"#; crate::parsing::top_level_parse("foo(42, fn(x) { return x + 1 })").unwrap(); } + #[test] + fn test_annotation_fn() { + crate::parsing::top_level_parse( + r#"fn foo() { + @annotated + return 1 +}"#, + ) + .unwrap(); + } + + #[test] + fn test_annotation_settings() { + crate::parsing::top_level_parse("@settings(units = mm)").unwrap(); + } + #[test] fn test_anon_fn_no_fn() { assert_err_contains("foo(42, (x) { return x + 1 })", "Anonymous function requires `fn`"); diff --git a/src/wasm-lib/kcl/src/parsing/token/mod.rs b/src/wasm-lib/kcl/src/parsing/token/mod.rs index 6b622b48a..559711eca 100644 --- a/src/wasm-lib/kcl/src/parsing/token/mod.rs +++ b/src/wasm-lib/kcl/src/parsing/token/mod.rs @@ -56,14 +56,13 @@ impl NumericSuffix { impl FromStr for NumericSuffix { type Err = CompilationError; - fn from_str(s: &str) -> std::result::Result { + fn from_str(s: &str) -> Result { match s { "_" => Ok(NumericSuffix::Count), "mm" => Ok(NumericSuffix::Mm), "cm" => Ok(NumericSuffix::Cm), "m" => Ok(NumericSuffix::M), - "inch" => Ok(NumericSuffix::Inch), - "in" => Ok(NumericSuffix::Inch), + "inch" | "in" => Ok(NumericSuffix::Inch), "ft" => Ok(NumericSuffix::Ft), "yd" => Ok(NumericSuffix::Yd), "deg" => Ok(NumericSuffix::Deg), diff --git a/src/wasm-lib/kcl/src/unparser.rs b/src/wasm-lib/kcl/src/unparser.rs index a02a2135b..92469f2f9 100644 --- a/src/wasm-lib/kcl/src/unparser.rs +++ b/src/wasm-lib/kcl/src/unparser.rs @@ -3,10 +3,10 @@ use std::fmt::Write; use crate::parsing::{ ast::types::{ ArrayExpression, ArrayRangeExpression, BinaryExpression, BinaryOperator, BinaryPart, BodyItem, CallExpression, - CallExpressionKw, DefaultParamVal, Expr, FnArgType, FormatOptions, FunctionExpression, IfExpression, - ImportSelector, ImportStatement, ItemVisibility, LabeledArg, Literal, LiteralIdentifier, LiteralValue, - MemberExpression, MemberObject, Node, NonCodeValue, ObjectExpression, Parameter, PipeExpression, Program, - TagDeclarator, UnaryExpression, VariableDeclaration, VariableKind, + CallExpressionKw, CommentStyle, DefaultParamVal, Expr, FnArgType, FormatOptions, FunctionExpression, + IfExpression, ImportSelector, ImportStatement, ItemVisibility, LabeledArg, Literal, LiteralIdentifier, + LiteralValue, MemberExpression, MemberObject, Node, NonCodeNode, NonCodeValue, ObjectExpression, Parameter, + PipeExpression, Program, TagDeclarator, UnaryExpression, VariableDeclaration, VariableKind, }, PIPE_OPERATOR, }; @@ -55,7 +55,7 @@ impl Program { self.non_code_meta .start_nodes .iter() - .map(|start| start.format(&indentation)) + .map(|start| start.recast(options, indentation_level)) .collect() } } else { @@ -76,7 +76,7 @@ impl Program { .iter() .enumerate() .map(|(i, custom_white_space_or_comment)| { - let formatted = custom_white_space_or_comment.format(&indentation); + let formatted = custom_white_space_or_comment.recast(options, indentation_level); if i == 0 && !formatted.trim().is_empty() { if let NonCodeValue::BlockComment { .. } = custom_white_space_or_comment.value { format!("\n{}", formatted) @@ -115,7 +115,75 @@ impl NonCodeValue { fn should_cause_array_newline(&self) -> bool { match self { Self::InlineComment { .. } => false, - Self::BlockComment { .. } | Self::NewLineBlockComment { .. } | Self::NewLine => true, + Self::BlockComment { .. } | Self::NewLineBlockComment { .. } | Self::NewLine | Self::Annotation { .. } => { + true + } + } + } +} + +impl Node { + fn recast(&self, options: &FormatOptions, indentation_level: usize) -> String { + let indentation = options.get_indentation(indentation_level); + match &self.value { + NonCodeValue::InlineComment { + value, + style: CommentStyle::Line, + } => format!(" // {}\n", value), + NonCodeValue::InlineComment { + value, + style: CommentStyle::Block, + } => format!(" /* {} */", value), + NonCodeValue::BlockComment { value, style } => match style { + CommentStyle::Block => format!("{}/* {} */", indentation, value), + CommentStyle::Line => { + if value.trim().is_empty() { + format!("{}//\n", indentation) + } else { + format!("{}// {}\n", indentation, value.trim()) + } + } + }, + NonCodeValue::NewLineBlockComment { value, style } => { + let add_start_new_line = if self.start == 0 { "" } else { "\n\n" }; + match style { + CommentStyle::Block => format!("{}{}/* {} */\n", add_start_new_line, indentation, value), + CommentStyle::Line => { + if value.trim().is_empty() { + format!("{}{}//\n", add_start_new_line, indentation) + } else { + format!("{}{}// {}\n", add_start_new_line, indentation, value.trim()) + } + } + } + } + NonCodeValue::NewLine => "\n\n".to_string(), + NonCodeValue::Annotation { name, properties } => { + let mut result = "@".to_owned(); + result.push_str(&name.name); + if let Some(properties) = properties { + result.push('('); + result.push_str( + &properties + .iter() + .map(|prop| { + format!( + "{} = {}", + prop.key.name, + prop.value + .recast(options, indentation_level + 1, ExprContext::Other) + .trim() + ) + }) + .collect::>() + .join(", "), + ); + result.push(')'); + result.push('\n'); + } + + result + } } } } @@ -343,7 +411,7 @@ impl ArrayExpression { .iter() .map(|nc| { found_line_comment |= nc.value.should_cause_array_newline(); - nc.format("") + nc.recast(options, 0) }) .collect::>() } else { @@ -481,7 +549,7 @@ impl ObjectExpression { let format_items: Vec<_> = (0..num_items) .flat_map(|i| { if let Some(noncode) = self.non_code_meta.non_code_nodes.get(&i) { - noncode.iter().map(|nc| nc.format("")).collect::>() + noncode.iter().map(|nc| nc.recast(options, 0)).collect::>() } else { let prop = props.next().unwrap(); // Use a comma unless it's the last item @@ -617,10 +685,13 @@ impl Node { if let Some(non_code_meta_value) = non_code_meta.non_code_nodes.get(&index) { for val in non_code_meta_value { let formatted = if val.end == self.end { - let indentation = options.get_indentation(indentation_level); - val.format(&indentation).trim_end_matches('\n').to_string() + val.recast(options, indentation_level) + .trim_end_matches('\n') + .to_string() } else { - val.format(&indentation).trim_end_matches('\n').to_string() + val.recast(options, indentation_level + 1) + .trim_end_matches('\n') + .to_string() }; if let NonCodeValue::BlockComment { .. } = val.value { s += "\n"; @@ -1252,7 +1323,8 @@ part001 = startSketchOn('XY') #[test] fn test_recast_large_file() { - let some_program_string = r#"// define nts + let some_program_string = r#"@settings(units=mm) +// define nts radius = 6.0 width = 144.0 length = 83.0 @@ -1376,7 +1448,8 @@ tabs_l = startSketchOn({ // Its VERY important this comes back with zero new lines. assert_eq!( recasted, - r#"// define nts + r#"@settings(units = mm) +// define nts radius = 6.0 width = 144.0 length = 83.0