allow nested files imported (#7090)

* allow nested files

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix test

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* disallow bad things

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* add playwright test on windows

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* add playwright test on windows

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixes

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix test

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* Update rust/kcl-lib/tests/nested_windows_main_kcl/unparsed@main.kcl.snap

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
This commit is contained in:
Jess Frazelle
2025-05-19 19:42:25 -07:00
committed by GitHub
parent d0697c24fd
commit 1b4289f93f
29 changed files with 866 additions and 33 deletions

View File

@ -35,31 +35,28 @@ impl Default for TypedPath {
impl From<&String> for TypedPath {
fn from(path: &String) -> Self {
#[cfg(target_arch = "wasm32")]
{
TypedPath(typed_path::TypedPath::derive(path).to_path_buf())
}
#[cfg(not(target_arch = "wasm32"))]
{
TypedPath(std::path::PathBuf::from(path))
}
TypedPath::new(path)
}
}
impl From<&str> for TypedPath {
fn from(path: &str) -> Self {
TypedPath::new(path)
}
}
impl TypedPath {
pub fn new(path: &str) -> Self {
#[cfg(target_arch = "wasm32")]
{
TypedPath(typed_path::TypedPath::derive(path).to_path_buf())
}
#[cfg(not(target_arch = "wasm32"))]
{
TypedPath(std::path::PathBuf::from(path))
TypedPath(normalise_import(path))
}
}
}
impl TypedPath {
pub fn extension(&self) -> Option<&str> {
#[cfg(target_arch = "wasm32")]
{
@ -85,6 +82,17 @@ impl TypedPath {
}
}
pub fn join_typed(&self, path: &TypedPath) -> Self {
#[cfg(target_arch = "wasm32")]
{
TypedPath(self.0.join(path.0.to_path()))
}
#[cfg(not(target_arch = "wasm32"))]
{
TypedPath(self.0.join(&path.0))
}
}
pub fn parent(&self) -> Option<Self> {
#[cfg(target_arch = "wasm32")]
{
@ -206,3 +214,19 @@ impl schemars::JsonSchema for TypedPath {
gen.subschema_for::<std::path::PathBuf>()
}
}
/// Turn `nested\foo\bar\main.kcl` or `nested/foo/bar/main.kcl`
/// into a PathBuf that works on the host OS.
///
/// * Does **not** touch `..` or symlinks call `canonicalize()` if you need that.
/// * Returns an owned `PathBuf` only when normalisation was required.
fn normalise_import<S: AsRef<str>>(raw: S) -> std::path::PathBuf {
let s = raw.as_ref();
// On Unix we need to swap `\` → `/`. On Windows we leave it alone.
// (Windows happily consumes `/`)
if cfg!(unix) && s.contains('\\') {
std::path::PathBuf::from(s.replace('\\', "/"))
} else {
std::path::Path::new(s).to_path_buf()
}
}

View File

@ -185,9 +185,9 @@ impl ModulePath {
match path {
ImportPath::Kcl { filename: path } | ImportPath::Foreign { path } => {
let resolved_path = if let Some(project_dir) = project_directory {
project_dir.join(path)
project_dir.join_typed(path)
} else {
TypedPath::from(path)
path.clone()
};
ModulePath::Local { value: resolved_path }
}

View File

@ -34,7 +34,7 @@ use crate::{
},
parsing::{ast::digest::Digest, token::NumericSuffix, PIPE_OPERATOR},
source_range::SourceRange,
ModuleId,
ModuleId, TypedPath,
};
mod condition;
@ -1741,8 +1741,8 @@ impl ImportSelector {
#[ts(export)]
#[serde(tag = "type")]
pub enum ImportPath {
Kcl { filename: String },
Foreign { path: String },
Kcl { filename: TypedPath },
Foreign { path: TypedPath },
Std { path: Vec<String> },
}
@ -1811,16 +1811,14 @@ impl ImportStatement {
match &self.path {
ImportPath::Kcl { filename: s } | ImportPath::Foreign { path: s } => {
let mut parts = s.split('.');
let path = parts.next()?;
let _ext = parts.next()?;
let rest = parts.next();
if rest.is_some() {
return None;
let name = s.file_name().map(|f| f.to_string());
// Remove the extension if it exists.
let extension = s.extension();
if let Some(extension) = extension {
name.map(|n| n.trim_end_matches(extension).trim_end_matches('.').to_string())
} else {
name
}
path.rsplit(&['/', '\\']).next().map(str::to_owned)
}
ImportPath::Std { path } => path.last().cloned(),
}

View File

@ -35,7 +35,7 @@ use crate::{
token::{Token, TokenSlice, TokenType},
PIPE_OPERATOR, PIPE_SUBSTITUTION_OPERATOR,
},
SourceRange, IMPORT_FILE_EXTENSIONS,
SourceRange, TypedPath, IMPORT_FILE_EXTENSIONS,
};
thread_local! {
@ -1862,7 +1862,7 @@ fn validate_path_string(path_string: String, var_name: bool, path_range: SourceR
let path = if path_string.ends_with(".kcl") {
if path_string
.chars()
.any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-' && c != '.')
.any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-' && c != '.' && c != '/' && c != '\\')
{
return Err(ErrMode::Cut(
CompilationError::fatal(
@ -1873,7 +1873,30 @@ fn validate_path_string(path_string: String, var_name: bool, path_range: SourceR
));
}
ImportPath::Kcl { filename: path_string }
if path_string.starts_with("..") {
return Err(ErrMode::Cut(
CompilationError::fatal(
path_range,
"import path may not start with '..'. Cannot traverse to something outside the bounds of your project. If this path is inside your project please find a better way to reference it.",
)
.into(),
));
}
// Make sure they are not using an absolute path.
if path_string.starts_with('/') || path_string.starts_with('\\') {
return Err(ErrMode::Cut(
CompilationError::fatal(
path_range,
"import path may not start with '/' or '\\'. Cannot traverse to something outside the bounds of your project. If this path is inside your project please find a better way to reference it.",
)
.into(),
));
}
ImportPath::Kcl {
filename: TypedPath::new(&path_string),
}
} else if path_string.starts_with("std::") {
ParseContext::warn(CompilationError::err(
path_range,
@ -1910,7 +1933,9 @@ fn validate_path_string(path_string: String, var_name: bool, path_range: SourceR
format!("unsupported import path format. KCL files can be imported from the current project, CAD files with the following formats are supported: {}", IMPORT_FILE_EXTENSIONS.join(", ")),
))
}
ImportPath::Foreign { path: path_string }
ImportPath::Foreign {
path: TypedPath::new(&path_string),
}
} else {
return Err(ErrMode::Cut(
CompilationError::fatal(
@ -4534,6 +4559,16 @@ e
fn bad_imports() {
assert_err(
r#"import cube from "../cube.kcl""#,
"import path may not start with '..'. Cannot traverse to something outside the bounds of your project. If this path is inside your project please find a better way to reference it.",
[17, 30],
);
assert_err(
r#"import cube from "/cube.kcl""#,
"import path may not start with '/' or '\\'. Cannot traverse to something outside the bounds of your project. If this path is inside your project please find a better way to reference it.",
[17, 28],
);
assert_err(
r#"import cube from "C:\cube.kcl""#,
"import path may only contain alphanumeric characters, underscore, hyphen, and period. KCL files in other directories are not yet supported.",
[17, 30],
);

View File

@ -3276,3 +3276,45 @@ mod subtract_regression10 {
super::execute(TEST_NAME, true).await
}
}
mod nested_main_kcl {
const TEST_NAME: &str = "nested_main_kcl";
/// Test parsing KCL.
#[test]
fn parse() {
super::parse(TEST_NAME)
}
/// Test that parsing and unparsing KCL produces the original KCL input.
#[tokio::test(flavor = "multi_thread")]
async fn unparse() {
super::unparse(TEST_NAME).await
}
/// Test that KCL is executed correctly.
#[tokio::test(flavor = "multi_thread")]
async fn kcl_test_execute() {
super::execute(TEST_NAME, true).await
}
}
mod nested_windows_main_kcl {
const TEST_NAME: &str = "nested_windows_main_kcl";
/// Test parsing KCL.
#[test]
fn parse() {
super::parse(TEST_NAME)
}
/// Test that parsing and unparsing KCL produces the original KCL input.
#[tokio::test(flavor = "multi_thread")]
async fn unparse() {
super::unparse(TEST_NAME).await
}
/// Test that KCL is executed correctly.
#[tokio::test(flavor = "multi_thread")]
async fn kcl_test_execute() {
super::execute(TEST_NAME, true).await
}
}