use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::config::{Settings, SourceLayout};
use crate::project_resolver::{
ResolutionResult, Sha256Hash, persist::ResolutionPersistence, sha::compute_file_sha,
};
pub fn extract_language_config_paths(settings: &Settings, language_id: &str) -> Vec<PathBuf> {
let Some(config) = settings.languages.get(language_id) else {
return Vec::new();
};
let mut paths = config.config_files.clone();
for project in &config.projects {
if !paths.contains(&project.config_file) {
paths.push(project.config_file.clone());
}
}
paths
}
pub fn is_language_enabled(settings: &Settings, language_id: &str) -> bool {
settings
.languages
.get(language_id)
.map(|config| config.enabled)
.unwrap_or(true)
}
pub fn compute_config_shas(configs: &[PathBuf]) -> ResolutionResult<HashMap<PathBuf, Sha256Hash>> {
let mut shas = HashMap::with_capacity(configs.len());
for config in configs {
if config.exists() {
let sha = compute_file_sha(config)?;
shas.insert(config.clone(), sha);
}
}
Ok(shas)
}
pub fn get_layout_for_config(
settings: &Settings,
language_id: &str,
config_path: &Path,
) -> Option<SourceLayout> {
let lang_config = settings.languages.get(language_id)?;
for project in &lang_config.projects {
let project_path = project.config_file.canonicalize().ok()?;
let target_path = config_path.canonicalize().ok()?;
if project_path == target_path {
return Some(project.source_layout);
}
}
None
}
pub fn module_for_file_generic(
file_path: &Path,
language_id: &str,
separator: &str,
) -> Option<String> {
let codanna_dir = Path::new(crate::init::local_dir_name());
let persistence = ResolutionPersistence::new(codanna_dir);
let index = persistence.load(language_id).ok()?;
let canon_file = file_path.canonicalize().ok()?;
let config_path = index.get_config_for_file(&canon_file)?;
let rules = index.rules.get(config_path)?;
for root_path in rules.paths.keys() {
let root = Path::new(root_path);
let canon_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
if let Ok(relative) = canon_file.strip_prefix(&canon_root) {
let module_path = relative
.parent()?
.to_string_lossy()
.replace(['/', '\\'], separator);
return Some(module_path);
}
}
None
}
pub fn parse_gradle_source_roots(
gradle_path: &Path,
source_suffix: &str,
layout: Option<SourceLayout>,
) -> ResolutionResult<Vec<PathBuf>> {
use std::fs;
let project_dir = gradle_path.parent().unwrap_or(Path::new("."));
if let Some(explicit_layout) = layout {
return Ok(match explicit_layout {
SourceLayout::Jvm => vec![
project_dir.join(format!("src/main/{source_suffix}")),
project_dir.join(format!("src/test/{source_suffix}")),
],
SourceLayout::StandardKmp => {
let src_dir = project_dir.join("src");
if src_dir.is_dir() {
discover_standard_kmp_roots(&src_dir, source_suffix)
} else {
Vec::new()
}
}
SourceLayout::FlatKmp => discover_flat_kmp_roots(project_dir),
});
}
let content = fs::read_to_string(gradle_path).map_err(|e| {
crate::project_resolver::ResolutionError::IoError {
path: gradle_path.to_path_buf(),
cause: e.to_string(),
}
})?;
let custom_dirs = parse_srcdirs_from_gradle(&content, source_suffix);
if !custom_dirs.is_empty() {
return Ok(custom_dirs
.into_iter()
.map(|d| project_dir.join(d))
.collect());
}
if is_kotlin_multiplatform(&content) && source_suffix == "kotlin" {
let discovered = discover_multiplatform_source_roots(project_dir, source_suffix);
if !discovered.is_empty() {
return Ok(discovered);
}
}
Ok(vec![
project_dir.join(format!("src/main/{source_suffix}")),
project_dir.join(format!("src/test/{source_suffix}")),
])
}
fn is_kotlin_multiplatform(content: &str) -> bool {
content.contains("kotlin(\"multiplatform\")")
|| content.contains("kotlin('multiplatform')")
|| content.contains("id 'org.jetbrains.kotlin.multiplatform'")
|| content.contains("id \"org.jetbrains.kotlin.multiplatform\"")
|| content.contains("id(\"org.jetbrains.kotlin.multiplatform\")")
|| content.contains("apply plugin: 'kotlin-multiplatform'")
|| content.contains("apply plugin: \"kotlin-multiplatform\"")
}
fn discover_multiplatform_source_roots(project_dir: &Path, source_suffix: &str) -> Vec<PathBuf> {
let mut roots = Vec::new();
let src_dir = project_dir.join("src");
if src_dir.is_dir() {
roots.extend(discover_standard_kmp_roots(&src_dir, source_suffix));
}
if roots.is_empty() {
roots.extend(discover_flat_kmp_roots(project_dir));
}
roots
}
fn discover_standard_kmp_roots(src_dir: &Path, source_suffix: &str) -> Vec<PathBuf> {
let mut roots = Vec::new();
let known_source_sets = [
"commonMain",
"commonTest",
"jvmMain",
"jvmTest",
"jsMain",
"jsTest",
"nativeMain",
"nativeTest",
"iosMain",
"iosTest",
"macosMain",
"macosTest",
"linuxMain",
"linuxTest",
"mingwMain",
"mingwTest",
"androidMain",
"androidTest",
"appleMain",
"appleTest",
"posixMain",
"posixTest",
"darwinMain",
"darwinTest",
"nix",
"nixMain",
"nixTest",
"wasmMain",
"wasmTest",
"wasmJsMain",
"wasmJsTest",
"wasmWasiMain",
"wasmWasiTest",
];
for source_set in &known_source_sets {
let source_root = src_dir.join(source_set).join(source_suffix);
if source_root.is_dir() {
roots.push(source_root);
}
}
if let Ok(entries) = std::fs::read_dir(src_dir) {
for entry in entries.flatten() {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
let source_root = entry.path().join(source_suffix);
if source_root.is_dir() && !roots.contains(&source_root) {
roots.push(source_root);
}
}
}
}
roots
}
fn discover_flat_kmp_roots(project_dir: &Path) -> Vec<PathBuf> {
use std::fs;
let mut roots = Vec::new();
let known_platforms = [
"common",
"jvm",
"js",
"native",
"ios",
"macos",
"linux",
"mingw",
"android",
"androidNative",
"apple",
"posix",
"darwin",
"nix",
"wasm",
"wasmJs",
"wasmWasi",
"web",
"windows",
"tvos",
"watchos",
"nonJvm",
"jvmAndPosix",
];
for platform in &known_platforms {
let source_root = project_dir.join(platform).join("src");
if source_root.is_dir() {
roots.push(source_root);
}
let test_root = project_dir.join(platform).join("test");
if test_root.is_dir() {
roots.push(test_root);
}
}
let skip_dirs = [
"build",
"api",
"gradle",
".gradle",
".git",
".idea",
"node_modules",
];
if let Ok(entries) = fs::read_dir(project_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if skip_dirs.contains(&name_str.as_ref()) {
continue;
}
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
let source_root = entry.path().join("src");
if source_root.is_dir() && !roots.contains(&source_root) {
roots.push(source_root);
}
let test_root = entry.path().join("test");
if test_root.is_dir() && !roots.contains(&test_root) {
roots.push(test_root);
}
}
}
}
roots
}
fn parse_srcdirs_from_gradle(content: &str, source_suffix: &str) -> Vec<String> {
use regex::Regex;
let mut dirs = Vec::new();
let set_src_dirs_re = Regex::new(r#"setSrcDirs\s*\(\s*listOf\s*\(([^)]+)\)"#).unwrap();
for cap in set_src_dirs_re.captures_iter(content) {
if let Some(paths) = cap.get(1) {
dirs.extend(extract_quoted_paths(paths.as_str()));
}
}
let src_dirs_array_re = Regex::new(r#"srcDirs\s*=\s*\[([^\]]+)\]"#).unwrap();
for cap in src_dirs_array_re.captures_iter(content) {
if let Some(paths) = cap.get(1) {
dirs.extend(extract_quoted_paths(paths.as_str()));
}
}
let src_dirs_func_re = Regex::new(r#"srcDirs\s*\(([^)]+)\)"#).unwrap();
for cap in src_dirs_func_re.captures_iter(content) {
if let Some(paths) = cap.get(1) {
let match_start = cap.get(0).unwrap().start();
if match_start > 0 && content[..match_start].ends_with("set") {
continue;
}
dirs.extend(extract_quoted_paths(paths.as_str()));
}
}
let src_dir_single_re = Regex::new(r#"srcDir\s+['"](.*?)['"]"#).unwrap();
for cap in src_dir_single_re.captures_iter(content) {
if let Some(path) = cap.get(1) {
dirs.push(path.as_str().to_string());
}
}
dirs.into_iter()
.filter(|d| d.contains(source_suffix) || !d.contains("java") && !d.contains("kotlin"))
.collect()
}
fn extract_quoted_paths(input: &str) -> Vec<String> {
let mut paths = Vec::new();
let quote_re = regex::Regex::new(r#"['"]([^'"]+)['"]"#).unwrap();
for cap in quote_re.captures_iter(input) {
if let Some(path) = cap.get(1) {
paths.push(path.as_str().to_string());
}
}
paths
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::LanguageConfig;
fn create_test_settings_with_language(
language_id: &str,
enabled: bool,
config_files: Vec<PathBuf>,
) -> Settings {
let mut settings = Settings::default();
let config = LanguageConfig {
enabled,
extensions: vec![],
parser_options: HashMap::new(),
config_files,
projects: Vec::new(),
};
settings.languages.insert(language_id.to_string(), config);
settings
}
#[test]
fn test_extract_language_config_paths_returns_configured_paths() {
let paths = vec![
PathBuf::from("/project/go.mod"),
PathBuf::from("/other/go.mod"),
];
let settings = create_test_settings_with_language("go", true, paths.clone());
let result = extract_language_config_paths(&settings, "go");
assert_eq!(result, paths);
}
#[test]
fn test_extract_language_config_paths_returns_empty_for_unconfigured() {
let settings = Settings::default();
let result = extract_language_config_paths(&settings, "go");
assert!(result.is_empty());
}
#[test]
fn test_is_language_enabled_returns_true_by_default() {
let settings = Settings::default();
assert!(is_language_enabled(&settings, "go"));
}
#[test]
fn test_is_language_enabled_respects_explicit_false() {
let settings = create_test_settings_with_language("go", false, vec![]);
assert!(!is_language_enabled(&settings, "go"));
}
#[test]
fn test_is_language_enabled_respects_explicit_true() {
let settings = create_test_settings_with_language("go", true, vec![]);
assert!(is_language_enabled(&settings, "go"));
}
#[test]
fn test_compute_config_shas_skips_nonexistent_files() {
let configs = vec![PathBuf::from("/definitely/does/not/exist/config.json")];
let result = compute_config_shas(&configs);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_compute_config_shas_computes_hash_for_existing_file() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "test content").unwrap();
let path = temp_file.path().to_path_buf();
let configs = vec![path.clone()];
let result = compute_config_shas(&configs).unwrap();
assert_eq!(result.len(), 1);
assert!(result.contains_key(&path));
assert!(!result.get(&path).unwrap().as_str().is_empty());
}
#[test]
fn test_parse_gradle_source_roots_java_defaults() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let gradle_path = temp_dir.path().join("build.gradle");
fs::write(&gradle_path, "plugins { id 'java' }").unwrap();
let roots = parse_gradle_source_roots(&gradle_path, "java", None).unwrap();
assert_eq!(roots.len(), 2);
assert!(roots.iter().any(|r| r.ends_with("src/main/java")));
assert!(roots.iter().any(|r| r.ends_with("src/test/java")));
}
#[test]
fn test_parse_gradle_source_roots_kotlin_defaults() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let gradle_path = temp_dir.path().join("build.gradle.kts");
fs::write(&gradle_path, "plugins { kotlin(\"jvm\") }").unwrap();
let roots = parse_gradle_source_roots(&gradle_path, "kotlin", None).unwrap();
assert_eq!(roots.len(), 2);
assert!(roots.iter().any(|r| r.ends_with("src/main/kotlin")));
assert!(roots.iter().any(|r| r.ends_with("src/test/kotlin")));
}
#[test]
fn test_parse_gradle_source_roots_with_set_srcdirs_kotlin() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let gradle_path = temp_dir.path().join("build.gradle.kts");
let content = r#"
sourceSets {
main {
kotlin.setSrcDirs(listOf("src/custom/kotlin", "src/generated/kotlin"))
}
}
"#;
fs::write(&gradle_path, content).unwrap();
let roots = parse_gradle_source_roots(&gradle_path, "kotlin", None).unwrap();
assert_eq!(roots.len(), 2);
assert!(roots.iter().any(|r| r.ends_with("src/custom/kotlin")));
assert!(roots.iter().any(|r| r.ends_with("src/generated/kotlin")));
}
#[test]
fn test_parse_gradle_source_roots_with_array_groovy() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let gradle_path = temp_dir.path().join("build.gradle");
let content = r#"
sourceSets {
main {
java.srcDirs = ['src/main/java', 'src/gen/java']
}
}
"#;
fs::write(&gradle_path, content).unwrap();
let roots = parse_gradle_source_roots(&gradle_path, "java", None).unwrap();
assert_eq!(roots.len(), 2);
assert!(roots.iter().any(|r| r.ends_with("src/main/java")));
assert!(roots.iter().any(|r| r.ends_with("src/gen/java")));
}
#[test]
fn test_parse_gradle_source_roots_with_srcdir_single() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let gradle_path = temp_dir.path().join("build.gradle");
let content = r#"
sourceSets {
main {
java {
srcDir 'src/extra/java'
}
}
}
"#;
fs::write(&gradle_path, content).unwrap();
let roots = parse_gradle_source_roots(&gradle_path, "java", None).unwrap();
assert_eq!(roots.len(), 1);
assert!(roots.iter().any(|r| r.ends_with("src/extra/java")));
}
#[test]
fn test_parse_srcdirs_filters_by_language() {
let content = r#"
sourceSets {
main {
java.srcDirs = ['src/main/java']
kotlin.srcDirs = ['src/main/kotlin']
}
}
"#;
let java_dirs = parse_srcdirs_from_gradle(content, "java");
let kotlin_dirs = parse_srcdirs_from_gradle(content, "kotlin");
assert_eq!(java_dirs.len(), 1);
assert_eq!(java_dirs[0], "src/main/java");
assert_eq!(kotlin_dirs.len(), 1);
assert_eq!(kotlin_dirs[0], "src/main/kotlin");
}
#[test]
fn test_is_kotlin_multiplatform_detects_kotlin_dsl() {
assert!(is_kotlin_multiplatform(
r#"plugins { kotlin("multiplatform") }"#
));
assert!(is_kotlin_multiplatform(
r#"plugins { kotlin('multiplatform') }"#
));
}
#[test]
fn test_is_kotlin_multiplatform_detects_groovy_dsl() {
assert!(is_kotlin_multiplatform(
r#"plugins { id 'org.jetbrains.kotlin.multiplatform' }"#
));
assert!(is_kotlin_multiplatform(
r#"plugins { id "org.jetbrains.kotlin.multiplatform" }"#
));
}
#[test]
fn test_is_kotlin_multiplatform_rejects_jvm_only() {
assert!(!is_kotlin_multiplatform(r#"plugins { kotlin("jvm") }"#));
assert!(!is_kotlin_multiplatform(r#"plugins { id 'java' }"#));
}
#[test]
fn test_discover_multiplatform_source_roots() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
fs::create_dir_all(project_dir.join("src/commonMain/kotlin")).unwrap();
fs::create_dir_all(project_dir.join("src/commonTest/kotlin")).unwrap();
fs::create_dir_all(project_dir.join("src/jvmMain/kotlin")).unwrap();
fs::create_dir_all(project_dir.join("src/jvmTest/kotlin")).unwrap();
fs::create_dir_all(project_dir.join("src/jsMain/kotlin")).unwrap();
let roots = discover_multiplatform_source_roots(project_dir, "kotlin");
assert_eq!(roots.len(), 5);
assert!(roots.iter().any(|r| r.ends_with("commonMain/kotlin")));
assert!(roots.iter().any(|r| r.ends_with("commonTest/kotlin")));
assert!(roots.iter().any(|r| r.ends_with("jvmMain/kotlin")));
assert!(roots.iter().any(|r| r.ends_with("jvmTest/kotlin")));
assert!(roots.iter().any(|r| r.ends_with("jsMain/kotlin")));
}
#[test]
fn test_parse_gradle_kmp_project_discovers_source_sets() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let gradle_path = project_dir.join("build.gradle.kts");
let content = r#"
plugins {
kotlin("multiplatform")
}
kotlin {
jvm()
js()
}
"#;
fs::write(&gradle_path, content).unwrap();
fs::create_dir_all(project_dir.join("src/commonMain/kotlin")).unwrap();
fs::create_dir_all(project_dir.join("src/jvmMain/kotlin")).unwrap();
fs::create_dir_all(project_dir.join("src/jsMain/kotlin")).unwrap();
let roots = parse_gradle_source_roots(&gradle_path, "kotlin", None).unwrap();
assert_eq!(roots.len(), 3);
assert!(roots.iter().any(|r| r.ends_with("commonMain/kotlin")));
assert!(roots.iter().any(|r| r.ends_with("jvmMain/kotlin")));
assert!(roots.iter().any(|r| r.ends_with("jsMain/kotlin")));
}
#[test]
fn test_discover_multiplatform_catches_custom_source_sets() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
fs::create_dir_all(project_dir.join("src/customMain/kotlin")).unwrap();
fs::create_dir_all(project_dir.join("src/commonMain/kotlin")).unwrap();
let roots = discover_multiplatform_source_roots(project_dir, "kotlin");
assert_eq!(roots.len(), 2);
assert!(roots.iter().any(|r| r.ends_with("commonMain/kotlin")));
assert!(roots.iter().any(|r| r.ends_with("customMain/kotlin")));
}
#[test]
fn test_discover_flat_kmp_roots_ktor_style() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
fs::create_dir_all(project_dir.join("common/src")).unwrap();
fs::create_dir_all(project_dir.join("common/test")).unwrap();
fs::create_dir_all(project_dir.join("jvm/src")).unwrap();
fs::create_dir_all(project_dir.join("posix/src")).unwrap();
fs::create_dir_all(project_dir.join("api")).unwrap();
let roots = discover_flat_kmp_roots(project_dir);
assert!(roots.len() >= 4);
assert!(roots.iter().any(|r| r.ends_with("common/src")));
assert!(roots.iter().any(|r| r.ends_with("common/test")));
assert!(roots.iter().any(|r| r.ends_with("jvm/src")));
assert!(roots.iter().any(|r| r.ends_with("posix/src")));
assert!(!roots.iter().any(|r| r.to_string_lossy().contains("api")));
}
#[test]
fn test_parse_gradle_flat_kmp_discovers_platform_dirs() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let gradle_path = project_dir.join("build.gradle.kts");
let content = r#"
plugins {
id("ktorbuild.project.library")
}
kotlin {
createCInterop("network", sourceSet = "nix")
}
"#;
fs::write(&gradle_path, content).unwrap();
fs::create_dir_all(project_dir.join("common/src")).unwrap();
fs::create_dir_all(project_dir.join("jvm/src")).unwrap();
let roots = parse_gradle_source_roots(&gradle_path, "kotlin", None).unwrap();
assert!(!roots.is_empty());
}
}