Merge remote-tracking branch 'origin/main' into paultag/import

This commit is contained in:
Paul R. Tagliamonte
2025-03-11 16:18:58 -04:00
200 changed files with 202329 additions and 5443 deletions

View File

@ -21,7 +21,7 @@ use crate::{
};
const TYPES_DIR: &str = "../../docs/kcl/types";
const LANG_TOPICS: [&str; 4] = ["Types", "Modules", "Settings", "Known Issues"];
const LANG_TOPICS: [&str; 5] = ["Types", "Modules", "Settings", "Known Issues", "Constants"];
// These types are declared in std.
const DECLARED_TYPES: [&str; 7] = ["number", "string", "tag", "bool", "Sketch", "Solid", "Plane"];
@ -298,6 +298,7 @@ fn init_handlebars() -> Result<handlebars::Handlebars<'static>> {
hbs.register_template_string("propertyType", include_str!("templates/propertyType.hbs"))?;
hbs.register_template_string("schema", include_str!("templates/schema.hbs"))?;
hbs.register_template_string("index", include_str!("templates/index.hbs"))?;
hbs.register_template_string("consts-index", include_str!("templates/consts-index.hbs"))?;
hbs.register_template_string("function", include_str!("templates/function.hbs"))?;
hbs.register_template_string("const", include_str!("templates/const.hbs"))?;
hbs.register_template_string("type", include_str!("templates/type.hbs"))?;
@ -312,6 +313,9 @@ fn generate_index(combined: &IndexMap<String, Box<dyn StdLibFn>>, kcl_lib: &[Doc
let mut functions = HashMap::new();
functions.insert("std".to_owned(), Vec::new());
let mut constants = HashMap::new();
constants.insert("std".to_owned(), Vec::new());
for key in combined.keys() {
let internal_fn = combined
.get(key)
@ -337,6 +341,13 @@ fn generate_index(combined: &IndexMap<String, Box<dyn StdLibFn>>, kcl_lib: &[Doc
DocData::Const(c) => (c.name.clone(), d.file_name()),
DocData::Ty(t) => (t.name.clone(), d.file_name()),
});
if let DocData::Const(c) = d {
constants
.entry(d.mod_name())
.or_default()
.push((c.name.clone(), d.file_name()));
}
}
// TODO we should sub-divide into types, constants, and functions.
@ -362,7 +373,7 @@ fn generate_index(combined: &IndexMap<String, Box<dyn StdLibFn>>, kcl_lib: &[Doc
.map(|name| {
json!({
"name": name,
"file_name": name.to_lowercase().replace(' ', "-"),
"file_name": name.to_lowercase().replace(' ', "-").replace("constants", "consts"),
})
})
.collect();
@ -375,6 +386,31 @@ fn generate_index(combined: &IndexMap<String, Box<dyn StdLibFn>>, kcl_lib: &[Doc
expectorate::assert_contents("../../docs/kcl/index.md", &output);
// Generate the index for the constants.
let mut sorted_consts: Vec<_> = constants
.into_iter()
.map(|(m, mut consts)| {
consts.sort();
let val = json!({
"name": m,
"consts": consts.into_iter().map(|(n, f)| json!({
"name": n,
"file_name": f,
})).collect::<Vec<_>>(),
});
(m, val)
})
.collect();
sorted_consts.sort_by(|t1, t2| t1.0.cmp(&t2.0));
let data: Vec<_> = sorted_consts.into_iter().map(|(_, val)| val).collect();
let data = json!({
"consts": data,
});
let output = hbs.render("consts-index", &data)?;
expectorate::assert_contents("../../docs/kcl/consts.md", &output);
Ok(())
}
@ -405,7 +441,7 @@ fn generate_example(index: usize, src: &str, props: &ExampleProperties, file_nam
}))
}
fn generate_type_from_kcl(ty: &TyData, file_name: String) -> Result<()> {
fn generate_type_from_kcl(ty: &TyData, file_name: String, example_name: String) -> Result<()> {
if ty.properties.doc_hidden {
return Ok(());
}
@ -416,7 +452,7 @@ fn generate_type_from_kcl(ty: &TyData, file_name: String) -> Result<()> {
.examples
.iter()
.enumerate()
.filter_map(|(index, example)| generate_example(index, &example.0, &example.1, &file_name))
.filter_map(|(index, example)| generate_example(index, &example.0, &example.1, &example_name))
.collect();
let data = json!({
@ -428,7 +464,7 @@ fn generate_type_from_kcl(ty: &TyData, file_name: String) -> Result<()> {
});
let output = hbs.render("kclType", &data)?;
expectorate::assert_contents(format!("../../docs/kcl/types/{}.md", file_name), &output);
expectorate::assert_contents(format!("../../docs/kcl/{}.md", file_name), &output);
Ok(())
}
@ -480,7 +516,7 @@ fn generate_function_from_kcl(function: &FnData, file_name: String) -> Result<()
Ok(())
}
fn generate_const_from_kcl(cnst: &ConstData, file_name: String) -> Result<()> {
fn generate_const_from_kcl(cnst: &ConstData, file_name: String, example_name: String) -> Result<()> {
if cnst.properties.doc_hidden {
return Ok(());
}
@ -490,7 +526,7 @@ fn generate_const_from_kcl(cnst: &ConstData, file_name: String) -> Result<()> {
.examples
.iter()
.enumerate()
.filter_map(|(index, example)| generate_example(index, &example.0, &example.1, &file_name))
.filter_map(|(index, example)| generate_example(index, &example.0, &example.1, &example_name))
.collect();
let data = json!({
@ -1028,8 +1064,8 @@ fn test_generate_stdlib_markdown_docs() {
for d in &kcl_std {
match d {
DocData::Fn(f) => generate_function_from_kcl(f, d.file_name()).unwrap(),
DocData::Const(c) => generate_const_from_kcl(c, d.file_name()).unwrap(),
DocData::Ty(t) => generate_type_from_kcl(t, d.file_name()).unwrap(),
DocData::Const(c) => generate_const_from_kcl(c, d.file_name(), d.example_name()).unwrap(),
DocData::Ty(t) => generate_type_from_kcl(t, d.file_name(), d.example_name()).unwrap(),
}
}
}
@ -1061,7 +1097,8 @@ fn test_generate_stdlib_json_schema() {
async fn test_code_in_topics() {
let mut join_set = JoinSet::new();
for name in LANG_TOPICS {
let filename = format!("../../docs/kcl/{}.md", name.to_lowercase().replace(' ', "-"));
let filename =
format!("../../docs/kcl/{}.md", name.to_lowercase().replace(' ', "-")).replace("constants", "consts");
let mut file = File::open(&filename).unwrap();
let mut text = String::new();
file.read_to_string(&mut text).unwrap();

View File

@ -116,10 +116,18 @@ impl DocData {
#[allow(dead_code)]
pub fn file_name(&self) -> String {
match self {
DocData::Fn(f) => f.qual_name.replace("::", "-"),
DocData::Const(c) => format!("consts/{}", c.qual_name.replace("::", "-")),
DocData::Ty(t) => format!("types/{}", t.name.clone()),
}
}
#[allow(dead_code)]
pub fn example_name(&self) -> String {
match self {
DocData::Fn(f) => f.qual_name.replace("::", "-"),
DocData::Const(c) => format!("const_{}", c.qual_name.replace("::", "-")),
// TODO might want to change this
DocData::Ty(t) => t.name.clone(),
}
}
@ -872,7 +880,7 @@ mod test {
Ok(img) => img,
};
twenty_twenty::assert_image(
format!("tests/outputs/serial_test_example_{}{i}.png", d.file_name()),
format!("tests/outputs/serial_test_example_{}{i}.png", d.example_name()),
&result,
0.99,
);

View File

@ -0,0 +1,17 @@
---
title: "KCL Constants"
excerpt: "Documentation for the KCL constants."
layout: manual
---
## Table of Contents
{{#each consts}}
### `{{name}}`
{{#each consts}}
- [`{{name}}`](/docs/kcl/{{file_name}})
{{/each}}
{{/each}}

View File

@ -64,8 +64,6 @@ pub struct ExecOutcome {
/// Operations that have been performed in execution order, for display in
/// the Feature Tree.
pub operations: Vec<Operation>,
/// Output map of UUIDs to artifacts.
pub artifacts: IndexMap<ArtifactId, Artifact>,
/// Output commands to allow building the artifact graph by the caller.
pub artifact_commands: Vec<ArtifactCommand>,
/// Output artifact graph.

View File

@ -123,7 +123,6 @@ impl ExecState {
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
operations: self.global.operations,
artifacts: self.global.artifacts,
artifact_commands: self.global.artifact_commands,
artifact_graph: self.global.artifact_graph,
errors: self.global.errors,
@ -146,7 +145,6 @@ impl ExecState {
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
operations: Default::default(),
artifacts: Default::default(),
artifact_commands: Default::default(),
artifact_graph: Default::default(),
errors: self.global.errors,

View File

@ -2809,29 +2809,45 @@ fn fn_call_kw(i: &mut TokenSlice) -> PResult<Node<CallExpressionKw>> {
let _ = open_paren.parse_next(i)?;
ignore_whitespace(i);
#[allow(clippy::large_enum_variant)]
pub enum ArgPlace {
NonCode(Node<NonCodeNode>),
LabeledArg(LabeledArg),
UnlabeledArg(Expr),
}
let initial_unlabeled_arg = opt((expression, comma, opt(whitespace)).map(|(arg, _, _)| arg)).parse_next(i)?;
let args: Vec<_> = repeat(
0..,
alt((
terminated(non_code_node.map(NonCodeOr::NonCode), whitespace),
terminated(labeled_argument, labeled_arg_separator).map(NonCodeOr::Code),
terminated(non_code_node.map(ArgPlace::NonCode), whitespace),
terminated(labeled_argument, labeled_arg_separator).map(ArgPlace::LabeledArg),
expression.map(ArgPlace::UnlabeledArg),
)),
)
.parse_next(i)?;
let (args, non_code_nodes): (Vec<_>, BTreeMap<usize, _>) = args.into_iter().enumerate().fold(
let (args, non_code_nodes): (Vec<_>, BTreeMap<usize, _>) = args.into_iter().enumerate().try_fold(
(Vec::new(), BTreeMap::new()),
|(mut args, mut non_code_nodes), (i, e)| {
match e {
NonCodeOr::NonCode(x) => {
ArgPlace::NonCode(x) => {
non_code_nodes.insert(i, vec![x]);
}
NonCodeOr::Code(x) => {
ArgPlace::LabeledArg(x) => {
args.push(x);
}
ArgPlace::UnlabeledArg(arg) => {
return Err(ErrMode::Cut(
CompilationError::fatal(
SourceRange::from(arg),
"This argument needs a label, but it doesn't have one",
)
.into(),
));
}
}
(args, non_code_nodes)
Ok((args, non_code_nodes))
},
);
)?;
if let Some(std_fn) = crate::std::get_stdlib_fn(&fn_name.name) {
let just_args: Vec<_> = args.iter().collect();
typecheck_all_kw(std_fn, &just_args)?;
@ -4641,6 +4657,27 @@ baz = 2
assert_eq!(actual.operator, UnaryOperator::Not);
crate::parsing::top_level_parse(some_program_string).unwrap(); // Updated import path
}
#[test]
fn test_sensible_error_when_missing_equals_in_kwarg() {
for (i, program) in ["f(x=1,y)", "f(x=1,y,z)", "f(x=1,y,z=1)", "f(x=1, y, z=1)"]
.into_iter()
.enumerate()
{
let tokens = crate::parsing::token::lex(program, ModuleId::default()).unwrap();
let err = fn_call_kw.parse(tokens.as_slice()).unwrap_err();
let cause = err.inner().cause.as_ref().unwrap();
assert_eq!(
cause.message, "This argument needs a label, but it doesn't have one",
"failed test {i}: {program}"
);
assert_eq!(
cause.source_range.start(),
program.find("y").unwrap(),
"failed test {i}: {program}"
);
}
}
}
#[cfg(test)]

View File

@ -0,0 +1,232 @@
use std::{fs, path::PathBuf};
use schemars::{gen::SchemaGenerator, JsonSchema};
use serde_json::{json, Value};
use crate::settings::types::{project::ProjectConfiguration, Configuration};
// Project settings example in TOML format
const PROJECT_SETTINGS_EXAMPLE: &str = r#"[settings.app]
# Set the appearance of the application
name = "My Awesome Project"
[settings.app.appearance]
# Use dark mode theme
theme = "dark"
# Set the app color to blue (240.0 = blue, 0.0 = red, 120.0 = green)
color = 240.0
[settings.modeling]
# Use inches as the default measurement unit
base_unit = "in"
"#;
// User settings example in TOML format
const USER_SETTINGS_EXAMPLE: &str = r#"[settings.app]
# Set the appearance of the application
[settings.app.appearance]
# Use dark mode theme
theme = "dark"
# Set the app color to blue (240.0 = blue, 0.0 = red, 120.0 = green)
color = 240.0
[settings.modeling]
# Use millimeters as the default measurement unit
base_unit = "mm"
[settings.text_editor]
# Disable text wrapping in the editor
text_wrapping = false
"#;
const PROJECT_SETTINGS_DOC_PATH: &str = "../../docs/kcl/settings/project.md";
const USER_SETTINGS_DOC_PATH: &str = "../../docs/kcl/settings/user.md";
fn init_handlebars() -> handlebars::Handlebars<'static> {
let mut hbs = handlebars::Handlebars::new();
// Register helper to pretty-format enum values
hbs.register_helper(
"pretty_enum",
Box::new(
|h: &handlebars::Helper,
_: &handlebars::Handlebars,
_: &handlebars::Context,
_: &mut handlebars::RenderContext,
out: &mut dyn handlebars::Output|
-> handlebars::HelperResult {
if let Some(enum_value) = h.param(0) {
if let Some(array) = enum_value.value().as_array() {
let pretty_options = array
.iter()
.filter_map(|v| v.as_str())
.map(|s| format!("`{}`", s))
.collect::<Vec<_>>()
.join(", ");
out.write(&pretty_options)?;
return Ok(());
}
}
out.write("No options available")?;
Ok(())
},
),
);
// Helper to format default values better
hbs.register_helper(
"format_default",
Box::new(
|h: &handlebars::Helper,
_: &handlebars::Handlebars,
_: &handlebars::Context,
_: &mut handlebars::RenderContext,
out: &mut dyn handlebars::Output|
-> handlebars::HelperResult {
if let Some(default) = h.param(0) {
let val = default.value();
match val {
Value::Null => out.write("None")?,
Value::Bool(b) => out.write(&b.to_string())?,
Value::Number(n) => out.write(&n.to_string())?,
Value::String(s) => out.write(&format!("`{}`", s))?,
Value::Array(arr) => {
let formatted = arr
.iter()
.map(|v| match v {
Value::String(s) => format!("`{}`", s),
_ => format!("{}", v),
})
.collect::<Vec<_>>()
.join(", ");
out.write(&format!("[{}]", formatted))?;
}
Value::Object(_) => out.write("(complex default)")?,
}
return Ok(());
}
out.write("None")?;
Ok(())
},
),
);
// Register the settings template
hbs.register_template_string("settings", include_str!("templates/settings.hbs"))
.expect("Failed to register settings template");
hbs
}
fn ensure_settings_dir() {
let settings_dir = PathBuf::from("../../docs/kcl/settings");
if !settings_dir.exists() {
fs::create_dir_all(&settings_dir).expect("Failed to create settings directory");
}
}
pub fn generate_settings_docs() {
ensure_settings_dir();
let hbs = init_handlebars();
// Generate project settings documentation
let mut settings = schemars::gen::SchemaSettings::default();
settings.inline_subschemas = true;
settings.meta_schema = None; // We don't need the meta schema for docs
settings.option_nullable = false; // Important - makes Option fields show properly
settings.option_add_null_type = false;
let mut generator = SchemaGenerator::new(settings.clone());
let project_schema = ProjectConfiguration::json_schema(&mut generator);
// For debugging the schema:
// fs::write("/tmp/project_schema.json", serde_json::to_string_pretty(&project_schema).unwrap())
// .expect("Failed to write debug schema");
// Extract the description from the schema metadata
let project_description = if let schemars::schema::Schema::Object(obj) = &project_schema {
if let Some(metadata) = &obj.metadata {
metadata.description.clone().unwrap_or_default()
} else {
"Project specific settings for the KittyCAD modeling app.".to_string()
}
} else {
"Project specific settings for the KittyCAD modeling app.".to_string()
};
// Convert the schema to our template format
let project_data = json!({
"title": "Project Settings",
"description": project_description,
"config_type": "Project Configuration",
"file_name": "project.toml",
"settings": json!(project_schema),
"example": PROJECT_SETTINGS_EXAMPLE
});
let project_output = hbs
.render("settings", &project_data)
.expect("Failed to render project settings documentation");
expectorate::assert_contents(PROJECT_SETTINGS_DOC_PATH, &project_output);
// Generate user settings documentation
let mut generator = SchemaGenerator::new(settings);
let user_schema = Configuration::json_schema(&mut generator);
// For debugging the schema:
// fs::write("/tmp/user_schema.json", serde_json::to_string_pretty(&user_schema).unwrap())
// .expect("Failed to write debug schema");
// Extract the description from the schema metadata
let user_description = if let schemars::schema::Schema::Object(obj) = &user_schema {
if let Some(metadata) = &obj.metadata {
metadata.description.clone().unwrap_or_default()
} else {
"User-specific configuration options for the KittyCAD modeling app.".to_string()
}
} else {
"User-specific configuration options for the KittyCAD modeling app.".to_string()
};
// Trim any trailing periods to avoid double periods
let user_data = json!({
"title": "User Settings",
"description": user_description,
"config_type": "User Configuration",
"file_name": "user.toml",
"settings": json!(user_schema),
"example": USER_SETTINGS_EXAMPLE
});
let user_output = hbs
.render("settings", &user_data)
.expect("Failed to render user settings documentation");
expectorate::assert_contents(USER_SETTINGS_DOC_PATH, &user_output);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_settings_docs() {
// First verify that our TOML examples are valid and match the expected types
let _project_config: ProjectConfiguration = toml::from_str(PROJECT_SETTINGS_EXAMPLE)
.expect("Project settings example is not valid according to ProjectConfiguration");
let _user_config: Configuration = toml::from_str(USER_SETTINGS_EXAMPLE)
.expect("User settings example is not valid according to Configuration");
// Expectorate will verify the output matches what we expect,
// or update it if run with EXPECTORATE=overwrite
generate_settings_docs();
// Verify files exist
let project_path = PathBuf::from(PROJECT_SETTINGS_DOC_PATH);
let user_path = PathBuf::from(USER_SETTINGS_DOC_PATH);
assert!(project_path.exists(), "Project settings documentation not generated");
assert!(user_path.exists(), "User settings documentation not generated");
}
}

View File

@ -1,3 +1,6 @@
//! This module contains settings for kcl projects as well as the modeling app.
pub mod types;
#[cfg(test)]
mod generate_settings_docs;

View File

@ -0,0 +1,67 @@
---
title: "{{title}}"
excerpt: "{{{description}}}"
layout: manual
---
# {{title}}
{{{description}}}
## {{config_type}} Structure
```toml
{{{example}}}
```
## Available Settings
{{#with settings.properties}}
{{#each this}}
### {{@key}}
{{#if metadata.description}}{{metadata.description}}{{/if}}
{{#with properties}}
{{#each this}}
#### {{@key}}
{{#if description}}{{description}}{{/if}}
{{#if enum}}
**Possible values:** {{pretty_enum enum}}
{{/if}}
**Default:** {{#if default}}{{format_default default}}{{else}}None{{/if}}
{{#if properties}}
This setting has the following nested options:
{{#each properties}}
##### {{@key}}
{{#if description}}{{description}}{{/if}}
{{#if enum}}
**Possible values:** {{pretty_enum enum}}
{{/if}}
**Default:** {{#if default}}{{format_default default}}{{else}}None{{/if}}
{{#if properties}}
This setting has further nested options. See the schema for full details.
{{/if}}
{{/each}}
{{/if}}
{{/each}}
{{/with}}
{{/each}}
{{/with}}
## Complete Example
```toml
{{{example}}}
```

View File

@ -11,7 +11,11 @@ use validator::{Validate, ValidateRange};
const DEFAULT_THEME_COLOR: f64 = 264.5;
const DEFAULT_PROJECT_NAME_TEMPLATE: &str = "project-$nnn";
/// High level configuration.
/// User specific settings for the app.
/// These live in `user.toml` in the app's configuration directory.
/// Updating the settings in the app will update this file automatically.
/// Do not edit this file manually, as it may be overwritten by the app.
/// Manual edits can cause corruption of the settings file.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]

View File

@ -10,7 +10,11 @@ use crate::settings::types::{
is_default, AppColor, CommandBarSettings, DefaultTrue, FloatOrInt, OnboardingStatus, TextEditorSettings, UnitLength,
};
/// High level project configuration.
/// Project specific settings for the app.
/// These live in `project.toml` in the base of the project directory.
/// Updating the settings for the project in the app will update this file automatically.
/// Do not edit this file manually, as it may be overwritten by the app.
/// Manual edits can cause corruption of the settings file.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]

View File

@ -27,7 +27,7 @@ struct Test {
}
pub(crate) const RENDERED_MODEL_NAME: &str = "rendered_model.png";
//pub(crate) const EXPORTED_STEP_NAME: &str = "exported_step.step";
pub(crate) const EXPORTED_STEP_NAME: &str = "exported_step.linux.step";
impl Test {
fn new(name: &str) -> Self {
@ -107,15 +107,7 @@ fn unparse_test(test: &Test) {
};
// Check recasting the AST produces the original string.
let actual = ast.recast(&Default::default(), 0);
if matches!(std::env::var("EXPECTORATE").as_deref(), Ok("overwrite")) {
std::fs::write(test.input_dir.join(&test.entry_point), &actual).unwrap();
}
let expected = read(&test.entry_point, &test.input_dir);
pretty_assertions::assert_eq!(
actual,
expected,
"Parse then unparse didn't recreate the original KCL file"
);
expectorate::assert_contents(test.input_dir.join(&test.entry_point), &actual);
}
async fn execute(test_name: &str, render_to_png: bool) {
@ -153,12 +145,13 @@ async fn execute_test(test: &Test, render_to_png: bool, export_step: bool) {
}
if export_step {
let step = step.unwrap();
// TODO FIXME: This is failing because the step file is not deterministic.
// But it should be, talk to @katie
/*assert_snapshot(test, "Step file", || {
insta::assert_binary_snapshot!(EXPORTED_STEP_NAME, step);
});*/
std::fs::write(test.output_dir.join("exported_step.snap.step"), step).unwrap();
let step_str = std::str::from_utf8(&step).unwrap();
// We use expectorate here so we can see the diff in ci.
expectorate::assert_contents(
test.output_dir
.join(format!("exported_step.{}.step", std::env::consts::OS)),
step_str,
);
}
let outcome = exec_state.to_wasm_outcome(env_ref);
assert_common_snapshots(

View File

@ -1,18 +1,12 @@
//! Run all the KCL samples in the `kcl_samples` directory.
//!
//! Use the `KCL_SAMPLES_ONLY=gear` environment variable to run only a subset of
//! the samples, in this case, all those that start with "gear".
use std::{
collections::HashMap,
fs,
io::Write,
path::{Path, PathBuf},
};
use anyhow::Result;
use fnv::FnvHashSet;
use serde::{Deserialize, Serialize};
use tokio::task::JoinSet;
use super::Test;
@ -25,76 +19,37 @@ lazy_static::lazy_static! {
static ref OUTPUTS_DIR: PathBuf = Path::new("tests/kcl_samples").to_path_buf();
}
#[test]
fn parse() {
#[kcl_directory_test_macro::test_all_dirs("../public/kcl-samples")]
fn parse(dir_name: &str, dir_path: &Path) {
let t = test(dir_name, dir_path.join("main.kcl").to_str().unwrap().to_owned());
let write_new = matches!(
std::env::var("INSTA_UPDATE").as_deref(),
Ok("auto" | "always" | "new" | "unseen")
);
let filter = filter_from_env();
let tests = kcl_samples_inputs(filter.as_deref());
let expected_outputs = kcl_samples_outputs(filter.as_deref());
assert!(!tests.is_empty(), "No KCL samples found");
let input_names = FnvHashSet::from_iter(tests.iter().map(|t| t.name.clone()));
for test in tests {
if write_new {
// Ensure the directory exists for new tests.
std::fs::create_dir_all(test.output_dir.clone()).unwrap();
}
super::parse_test(&test);
if write_new {
// Ensure the directory exists for new tests.
std::fs::create_dir_all(t.output_dir.clone()).unwrap();
}
// Ensure that inputs aren't missing.
let missing = expected_outputs
.into_iter()
.filter(|name| !input_names.contains(name))
.collect::<Vec<_>>();
assert!(missing.is_empty(), "Expected input kcl-samples for the following. If these are no longer tests, delete the expected output directories for them in {}: {missing:?}", OUTPUTS_DIR.to_string_lossy());
super::parse_test(&t);
}
#[test]
fn unparse() {
#[kcl_directory_test_macro::test_all_dirs("../public/kcl-samples")]
fn unparse(dir_name: &str, dir_path: &Path) {
// kcl-samples don't always use correct formatting. We don't ignore the
// test because we want to allow the just command to work. It's actually
// fine when no test runs.
}
#[tokio::test(flavor = "multi_thread")]
async fn kcl_test_execute() {
let filter = filter_from_env();
let tests = kcl_samples_inputs(filter.as_deref());
let expected_outputs = kcl_samples_outputs(filter.as_deref());
#[kcl_directory_test_macro::test_all_dirs("../public/kcl-samples")]
async fn kcl_test_execute(dir_name: &str, dir_path: &Path) {
let t = test(dir_name, dir_path.join("main.kcl").to_str().unwrap().to_owned());
super::execute_test(&t, true, true).await;
}
assert!(!tests.is_empty(), "No KCL samples found");
// Note: This is unordered.
let mut tasks = JoinSet::new();
// Mapping from task ID to test index.
let mut id_to_index = HashMap::new();
// Spawn a task for each test.
for (index, test) in tests.iter().cloned().enumerate() {
let handle = tasks.spawn(async move {
super::execute_test(&test, true, true).await;
});
id_to_index.insert(handle.id(), index);
}
// Join all the tasks and collect the failures. We cannot just join_all
// because insta's error messages don't clearly indicate which test failed.
let mut failed = vec![None; tests.len()];
while let Some(result) = tasks.join_next().await {
let Err(err) = result else {
continue;
};
// When there's an error, store the test name and error message.
let index = *id_to_index.get(&err.id()).unwrap();
failed[index] = Some(format!("{}: {err}", &tests[index].name));
}
let failed = failed.into_iter().flatten().collect::<Vec<_>>();
assert!(failed.is_empty(), "Failed tests: {}", failed.join("\n"));
#[test]
fn test_after_engine_ensure_kcl_samples_manifest_etc() {
let tests = kcl_samples_inputs();
let expected_outputs = kcl_samples_outputs();
// Ensure that inputs aren't missing.
let input_names = FnvHashSet::from_iter(tests.iter().map(|t| t.name.clone()));
@ -125,7 +80,7 @@ async fn kcl_test_execute() {
)
.unwrap();
let step_file = OUTPUTS_DIR.join(&tests.name).join("exported_step.snap.step");
let step_file = OUTPUTS_DIR.join(&tests.name).join(super::EXPORTED_STEP_NAME);
if !step_file.exists() {
panic!("Missing step for test: {}", tests.name);
}
@ -147,7 +102,7 @@ async fn kcl_test_execute() {
}
#[test]
fn generate_manifest() {
fn test_after_engine_generate_manifest() {
// Generate the manifest.json
generate_kcl_manifest(&INPUTS_DIR).unwrap();
}
@ -161,11 +116,7 @@ fn test(test_name: &str, entry_point: String) -> Test {
}
}
fn filter_from_env() -> Option<String> {
std::env::var("KCL_SAMPLES_ONLY").ok().filter(|s| !s.is_empty())
}
fn kcl_samples_inputs(filter: Option<&str>) -> Vec<Test> {
fn kcl_samples_inputs() -> Vec<Test> {
let mut tests = Vec::new();
// Collect all directory entries first and sort them by name for consistent ordering
@ -197,11 +148,6 @@ fn kcl_samples_inputs(filter: Option<&str>) -> Vec<Test> {
// Skip output directories.
continue;
}
if let Some(filter) = &filter {
if !dir_name_str.starts_with(filter) {
continue;
}
}
eprintln!("Found KCL sample: {:?}", dir_name.to_string_lossy());
// Look for the entry point inside the directory.
let sub_dir = INPUTS_DIR.join(dir_name);
@ -216,7 +162,7 @@ fn kcl_samples_inputs(filter: Option<&str>) -> Vec<Test> {
tests
}
fn kcl_samples_outputs(filter: Option<&str>) -> Vec<String> {
fn kcl_samples_outputs() -> Vec<String> {
let mut outputs = Vec::new();
for entry in OUTPUTS_DIR.read_dir().unwrap() {
@ -234,11 +180,6 @@ fn kcl_samples_outputs(filter: Option<&str>) -> Vec<String> {
// Skip hidden.
continue;
}
if let Some(filter) = &filter {
if !dir_name_str.starts_with(filter) {
continue;
}
}
eprintln!("Found expected KCL sample: {:?}", &dir_name_str);
outputs.push(dir_name_str.into_owned());
@ -352,10 +293,7 @@ fn generate_kcl_manifest(dir: &Path) -> Result<()> {
// Write the manifest.json
let output_path = dir.join(MANIFEST_FILE);
let manifest_json = serde_json::to_string_pretty(&manifest)?;
let mut file = fs::File::create(output_path.clone())?;
file.write_all(manifest_json.as_bytes())?;
expectorate::assert_contents(&output_path, &serde_json::to_string_pretty(&manifest).unwrap());
println!(
"Manifest of {} items written to {}",
@ -391,7 +329,7 @@ fn update_readme(dir: &Path, new_content: &str) -> Result<()> {
let updated_content = format!("{}{}\n", &content[..position], new_content);
// Write the modified content back to the file
std::fs::write(readme_path, updated_content)?;
expectorate::assert_contents(&readme_path, &updated_content);
Ok(())
}

View File

@ -182,13 +182,22 @@ impl Args {
}))?;
T::from_kcl_val(&arg.value).ok_or_else(|| {
let expected_type_name = tynm::type_name::<T>();
let actual_type_name = arg.value.human_friendly_type();
let msg_base = format!("This function expected this argument to be of type {expected_type_name} but it's actually of type {actual_type_name}");
let suggestion = match (expected_type_name.as_str(), actual_type_name) {
("SolidSet", "Sketch") => Some(
"You can convert a sketch (2D) into a Solid (3D) by calling a function like `extrude` or `revolve`",
),
_ => None,
};
let message = match suggestion {
None => msg_base,
Some(sugg) => format!("{msg_base}. {sugg}"),
};
KclError::Semantic(KclErrorDetails {
source_ranges: arg.source_ranges(),
message: format!(
"Expected a {} but found {}",
type_name::<T>(),
arg.value.human_friendly_type()
),
message,
})
})
}