diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..6313b56
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 96c3a3b..3c8f69e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,3 +28,55 @@ jobs:
echo "::group::Build" && cargo build --verbose && echo "::endgroup::" &&
echo "::group::Run tests" && cargo test --verbose && echo "::endgroup::" &&
echo "::group::Run lints" && cargo clippy --all-targets -- -D warnings
+
+ test-cross-platform:
+ name: Test on ${{ matrix.target }} (${{ matrix.os }})
+ needs:
+ - build
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # Linux (Standard & ARM)
+ - target: x86_64-unknown-linux-gnu
+ os: ubuntu-latest
+ - target: aarch64-unknown-linux-gnu
+ os: ubuntu-latest
+ # macOS (Intel & Apple Silicon)
+ - target: x86_64-apple-darwin
+ os: macos-latest
+ - target: aarch64-apple-darwin
+ os: macos-latest
+ # Windows
+ - target: x86_64-pc-windows-msvc
+ os: windows-latest
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Rust Cache
+ uses: Swatinem/rust-cache@v2
+
+ - name: Install Linker (Linux ARM)
+ if: matrix.target == 'aarch64-unknown-linux-gnu'
+ run: |
+ sudo apt-get update
+ sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
+ gcc-aarch64-linux-gnu \
+ libc6-dev-arm64-cross
+ echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
+ echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
+
+ - name: Build Binary
+ run: cargo build --verbose --target ${{ matrix.target }}
+
+ - name: Run Tests
+ # We only run tests if the target matches the runner's native architecture
+ # to avoid execution errors on cross-compiled binaries.
+ if: contains(matrix.target, 'x86_64') || (contains(matrix.target, 'aarch64') && contains(matrix.os, 'macos'))
+ run: cargo test --verbose --target ${{ matrix.target }}
diff --git a/src/config/workspace.rs b/src/config/workspace.rs
index db3c838..f86aaed 100644
--- a/src/config/workspace.rs
+++ b/src/config/workspace.rs
@@ -270,11 +270,11 @@ mod test {
let f = tmpdir.path().join("protols.toml");
std::fs::write(f, include_str!("input/protols-valid.toml")).unwrap();
+ let absolute_path = tmpdir.path().join("absolute_test_path");
+
// Set CLI include paths
- let cli_paths = vec![
- PathBuf::from("/path/to/protos"),
- PathBuf::from("relative/path"),
- ];
+ let cli_paths = vec![absolute_path.clone(), PathBuf::from("relative/path")];
+
let mut ws = WorkspaceProtoConfigs::new(cli_paths, None);
ws.add_workspace(&WorkspaceFolder {
uri: Url::from_directory_path(tmpdir.path()).unwrap(),
@@ -284,19 +284,12 @@ mod test {
let inworkspace = Url::from_file_path(tmpdir.path().join("foobar.proto")).unwrap();
let include_paths = ws.get_include_paths(&inworkspace).unwrap();
- // Check that CLI paths are included in the result
- assert!(
- include_paths
- .iter()
- .any(|p| p.ends_with("relative/path") || p == &PathBuf::from("/path/to/protos"))
- );
+ // The absolute path should be included as is
+ assert!(include_paths.contains(&absolute_path));
// The relative path should be resolved relative to the workspace
let resolved_relative_path = tmpdir.path().join("relative/path");
assert!(include_paths.contains(&resolved_relative_path));
-
- // The absolute path should be included as is
- assert!(include_paths.contains(&PathBuf::from("/path/to/protos")));
}
#[test]
@@ -305,10 +298,13 @@ mod test {
let f = tmpdir.path().join("protols.toml");
std::fs::write(f, include_str!("input/protols-valid.toml")).unwrap();
+ let cli_absolute_path = tmpdir.path().join("cli/path");
+ let init_absolute_path = tmpdir.path().join("init/path1");
+
// Set both CLI and initialization include paths
- let cli_paths = vec![PathBuf::from("/cli/path")];
+ let cli_paths = vec![cli_absolute_path.clone()];
let init_paths = vec![
- PathBuf::from("/init/path1"),
+ init_absolute_path.clone(),
PathBuf::from("relative/init/path"),
];
@@ -323,14 +319,14 @@ mod test {
let include_paths = ws.get_include_paths(&inworkspace).unwrap();
// Check that initialization paths are included
- assert!(include_paths.contains(&PathBuf::from("/init/path1")));
+ assert!(include_paths.contains(&init_absolute_path));
// The relative path should be resolved relative to the workspace
let resolved_relative_path = tmpdir.path().join("relative/init/path");
assert!(include_paths.contains(&resolved_relative_path));
// CLI paths should still be included
- assert!(include_paths.contains(&PathBuf::from("/cli/path")));
+ assert!(include_paths.contains(&cli_absolute_path));
}
#[test]
diff --git a/src/formatter/clang.rs b/src/formatter/clang.rs
index 1d6c753..12911f8 100644
--- a/src/formatter/clang.rs
+++ b/src/formatter/clang.rs
@@ -43,7 +43,13 @@ impl Replacement<'_> {
if offset > content.len() {
return None;
}
- let up_to_offset = &content[..offset];
+
+ // Use floor_char_boundary to ensure we don't slice in the middle of a
+ // multi-byte UTF-8 character (e.g., Cyrillic), which would cause a panic.
+ // This handles slight offset shifts caused by different OS line endings.
+ let safe_offset = content.floor_char_boundary(offset);
+
+ let up_to_offset = &content[..safe_offset];
let line = up_to_offset.matches('\n').count();
let last_newline = up_to_offset.rfind('\n').map_or(0, |pos| pos + 1);
@@ -202,13 +208,27 @@ mod test {
// Test that the complete flow works with Cyrillic characters
// This simulates what clang-format would output for the Cyrillic comment
let content = include_str!("input/test_cyrillic.proto");
- let xml_output = r#"
+
+ // We use a dynamic offset instead of a hardcoded byte index (like 134)
+ // because Windows uses CRLF (\r\n) while Linux uses LF (\n).
+ // Git's autocrlf can shift byte positions on Windows, potentially
+ // landing a fixed offset in the middle of a multi-byte UTF-8 character
+ // (like Cyrillic). Finding the target string in memory ensures we hit
+ // the correct character boundary regardless of the OS line endings.
+ let target = " removed_not_true";
+ let offset = content
+ .find(target)
+ .expect("Could not find target in content");
+ let xml_output = format!(
+ r#"
-
+
//
-"#;
+"#,
+ offset
+ );
- let r = Replacements::from_str(xml_output).unwrap();
+ let r = Replacements::from_str(&xml_output).unwrap();
assert_eq!(r.replacements.len(), 1);
let replacement = &r.replacements[0];
diff --git a/src/workspace/workspace_symbol.rs b/src/workspace/workspace_symbol.rs
index 845df0e..7e91b43 100644
--- a/src/workspace/workspace_symbol.rs
+++ b/src/workspace/workspace_symbol.rs
@@ -1,5 +1,6 @@
#[cfg(test)]
mod test {
+ use async_lsp::lsp_types::Url;
use insta::assert_yaml_snapshot;
use crate::config::Config;
@@ -9,24 +10,14 @@ mod test {
fn test_workspace_symbols() {
let current_dir = std::env::current_dir().unwrap();
let ipath = vec![current_dir.join("src/workspace/input")];
- let a_uri = format!(
- "file://{}/src/workspace/input/a.proto",
- current_dir.to_str().unwrap()
- )
- .parse()
- .unwrap();
- let b_uri = format!(
- "file://{}/src/workspace/input/b.proto",
- current_dir.to_str().unwrap()
- )
- .parse()
- .unwrap();
- let c_uri = format!(
- "file://{}/src/workspace/input/c.proto",
- current_dir.to_str().unwrap()
- )
- .parse()
- .unwrap();
+ let base_uri_str = Url::from_directory_path(¤t_dir)
+ .unwrap()
+ .to_string()
+ .trim_end_matches('/')
+ .to_string();
+ let a_uri = Url::from_file_path(current_dir.join("src/workspace/input/a.proto")).unwrap();
+ let b_uri = Url::from_file_path(current_dir.join("src/workspace/input/b.proto")).unwrap();
+ let c_uri = Url::from_file_path(current_dir.join("src/workspace/input/c.proto")).unwrap();
let a = include_str!("input/a.proto");
let b = include_str!("input/b.proto");
@@ -39,47 +30,49 @@ mod test {
// Test empty query - should return all symbols
let all_symbols = state.find_workspace_symbols("");
- let cdir = current_dir.to_str().unwrap().to_string();
+ let base_uri_1 = base_uri_str.clone();
assert_yaml_snapshot!(all_symbols, { "[].location.uri" => insta::dynamic_redaction(move |c, _| {
+ let uri_str = c.as_str().unwrap();
+
assert!(
- c.as_str()
- .unwrap()
- .contains(&cdir)
+ uri_str.contains(&base_uri_1),
+ "URI {} should contain {}", uri_str, base_uri_1
);
- format!(
- "file:///src/workspace/input/{}",
- c.as_str().unwrap().split('/').next_back().unwrap()
- )
+
+ let file_name = uri_str.split('/').next_back().unwrap();
+ format!("file:///src/workspace/input/{}", file_name)
})});
// Test query for "author" - should match Author and Address
let author_symbols = state.find_workspace_symbols("author");
- let cdir = current_dir.to_str().unwrap().to_string();
+ let base_uri_2 = base_uri_str.clone();
assert_yaml_snapshot!(author_symbols, {"[].location.uri" => insta::dynamic_redaction(move |c ,_|{
+ let uri_str = c.as_str().unwrap();
+
assert!(
- c.as_str()
- .unwrap()
- .contains(&cdir)
+ uri_str.contains(&base_uri_2),
+ "URI {} should contain {}", uri_str, base_uri_2
);
- format!(
- "file:///src/workspace/input/{}",
- c.as_str().unwrap().split('/').next_back().unwrap()
- )
+
+ let file_name = uri_str.split('/').next_back().unwrap();
+ format!("file:///src/workspace/input/{}", file_name)
})});
// Test query for "address" - should match Address
let address_symbols = state.find_workspace_symbols("address");
+ let base_uri_3 = base_uri_str.clone();
assert_yaml_snapshot!(address_symbols, {"[].location.uri" => insta::dynamic_redaction(move |c ,_|{
+ let uri_str = c.as_str().unwrap();
+
assert!(
- c.as_str()
- .unwrap()
- .contains(current_dir.to_str().unwrap())
+ uri_str.contains(&base_uri_3),
+ "URI {} should contain {}", uri_str, base_uri_3
);
- format!(
- "file:///src/workspace/input/{}",
- c.as_str().unwrap().split('/').next_back().unwrap()
- )
+
+
+ let file_name = uri_str.split('/').next_back().unwrap();
+ format!("file:///src/workspace/input/{}", file_name)
})});
// Test query that should not match anything