Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,11 +317,17 @@ CRITICAL rules for the `## Public API` section:
- If a symbol is private/internal (e.g. `const`, `fn` without `pub`, `mod` declarations), do NOT put it in the Public API table
- Use subsection headers like `### Exported Functions`, `### Exported Types` — NOT `### Constants`, `### Per-language extractors`, `### Methods`, etc.

Context boundaries:
- This spec covers ONLY the files listed in the frontmatter — do not document symbols from imported/dependent modules
- If a file imports symbols from other modules, those belong to the dependency's spec, not this one
- Only document the public contract that THIS module exposes to its consumers
- Group related types and functions logically, not by file

Other guidelines:
- For `## Invariants`, list rules that must always hold based on the code
- For `## Behavioral Examples`, use Given/When/Then format
- For `## Error Cases`, use a table of Condition | Behavior
- For `## Dependencies`, list what this module consumes from other modules
- For `## Dependencies`, list what this module consumes from other modules (imports from outside this module's files)
- For `## Change Log`, add a single entry with today's date and "Initial spec"
- Be accurate — only document what the code actually does"#
)
Expand Down
44 changes: 42 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::exports::has_extension;
use crate::manifest;
use crate::types::SpecSyncConfig;
use std::collections::HashSet;
use std::fs;
Expand Down Expand Up @@ -46,9 +47,32 @@ const IGNORED_DIRS: &[&str] = &[
"obj",
];

/// Auto-detect source directories by scanning the project root for files
/// with supported language extensions. Returns directories relative to root.
/// Auto-detect source directories by first checking manifest files
/// (Cargo.toml, Package.swift, build.gradle.kts, package.json, etc.),
/// then falling back to scanning the project root for files with supported
/// language extensions. Returns directories relative to root.
pub fn detect_source_dirs(root: &Path) -> Vec<String> {
// Try manifest-aware detection first
let manifest_discovery = manifest::discover_from_manifests(root);
if !manifest_discovery.source_dirs.is_empty() {
let mut dirs = manifest_discovery.source_dirs;
dirs.sort();
dirs.dedup();
return dirs;
}

// Fall back to directory scanning
detect_source_dirs_by_scan(root)
}

/// Discover modules from manifest files (Package.swift, Cargo.toml, etc.).
/// Returns the manifest discovery result for use in module detection.
pub fn discover_manifest_modules(root: &Path) -> manifest::ManifestDiscovery {
manifest::discover_from_manifests(root)
}

/// Scan-based source directory detection (fallback when no manifests found).
fn detect_source_dirs_by_scan(root: &Path) -> Vec<String> {
let ignored: HashSet<&str> = IGNORED_DIRS.iter().copied().collect();
let mut source_dirs: Vec<String> = Vec::new();
let mut has_root_source_files = false;
Expand Down Expand Up @@ -151,6 +175,8 @@ const KNOWN_JSON_KEYS: &[&str] = &[
"excludeDirs",
"excludePatterns",
"sourceExtensions",
"exportLevel",
"modules",
"aiProvider",
"aiModel",
"aiCommand",
Expand Down Expand Up @@ -249,6 +275,20 @@ fn load_toml_config(config_path: &Path, root: &Path) -> SpecSyncConfig {
config.ai_timeout = Some(n);
}
}
"export_level" => {
let s = parse_toml_string(value);
match s.as_str() {
"type" => {
config.export_level = crate::types::ExportLevel::Type;
}
"member" => {
config.export_level = crate::types::ExportLevel::Member;
}
_ => eprintln!(
"Warning: unknown export_level \"{s}\" (expected \"type\" or \"member\")"
),
}
}
"required_sections" => {
config.required_sections = parse_toml_string_array(value);
}
Expand Down
123 changes: 118 additions & 5 deletions src/exports/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,19 @@ mod rust_lang;
mod swift;
mod typescript;

use crate::types::Language;
use crate::types::{ExportLevel, Language};
use std::path::Path;

/// Extract exported symbol names from a source file, auto-detecting language.
/// Uses `ExportLevel::Member` (all symbols) for backwards compatibility.
pub fn get_exported_symbols(file_path: &Path) -> Vec<String> {
get_exported_symbols_with_level(file_path, ExportLevel::Member)
}

/// Extract exported symbol names from a source file with configurable granularity.
/// When `level` is `Type`, only top-level type declarations are returned.
/// When `level` is `Member`, all public symbols are returned (default).
pub fn get_exported_symbols_with_level(file_path: &Path, level: ExportLevel) -> Vec<String> {
let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");

let lang = match Language::from_extension(ext) {
Expand All @@ -29,9 +37,7 @@ pub fn get_exported_symbols(file_path: &Path) -> Vec<String> {
Language::TypeScript => {
// Build a resolver that follows wildcard re-exports to sibling files
let base_dir = file_path.parent().unwrap_or(Path::new(".")).to_path_buf();
let resolver = move |import_path: &str| {
resolve_ts_import(&base_dir, import_path)
};
let resolver = move |import_path: &str| resolve_ts_import(&base_dir, import_path);
typescript::extract_exports_with_resolver(&content, Some(&resolver))
}
Language::Rust => rust_lang::extract_exports(&content),
Expand All @@ -44,6 +50,13 @@ pub fn get_exported_symbols(file_path: &Path) -> Vec<String> {
Language::Dart => dart::extract_exports(&content),
};

// If type-level granularity, filter to only type declarations
let symbols = if level == ExportLevel::Type {
filter_type_level_exports(&content, &symbols, lang)
} else {
symbols
};

// Deduplicate preserving order
let mut seen = std::collections::HashSet::new();
symbols
Expand All @@ -52,6 +65,77 @@ pub fn get_exported_symbols(file_path: &Path) -> Vec<String> {
.collect()
}

/// Filter symbols to only include type-level declarations (class, struct, enum, etc.).
/// This removes individual functions, variables, constants, and properties.
fn filter_type_level_exports(content: &str, symbols: &[String], lang: Language) -> Vec<String> {
use regex::Regex;

let type_pattern = match lang {
Language::TypeScript => {
// class, interface, type, enum — but not function, const, var, let
Regex::new(
r"(?m)export\s+(?:default\s+)?(?:abstract\s+)?(?:class|interface|type|enum)\s+(\w+)",
)
.ok()
}
Language::Rust => {
Regex::new(r"(?m)pub(?:\(crate\))?\s+(?:struct|enum|trait|type|mod)\s+(\w+)").ok()
}
Language::Go => {
// Go: type X struct/interface
Regex::new(r"(?m)^type\s+([A-Z]\w*)\s+(?:struct|interface)").ok()
}
Language::Python => {
Regex::new(r"(?m)^class\s+(\w+)").ok()
}
Language::Swift => {
Regex::new(
r"(?m)(?:public|open)\s+(?:final\s+)?(?:class|struct|enum|protocol|actor)\s+(\w+)",
)
.ok()
}
Language::Kotlin => {
Regex::new(
r"(?m)(?:public\s+|open\s+|abstract\s+|sealed\s+)*(?:class|interface|enum\s+class|object|data\s+class)\s+(\w+)",
)
.ok()
}
Language::Java => {
Regex::new(
r"(?m)(?:public\s+)?(?:abstract\s+|final\s+)?(?:class|interface|enum|record)\s+(\w+)",
)
.ok()
}
Language::CSharp => {
Regex::new(
r"(?m)(?:public\s+)?(?:abstract\s+|sealed\s+|static\s+)?(?:class|interface|enum|struct|record)\s+(\w+)",
)
.ok()
}
Language::Dart => {
Regex::new(r"(?m)(?:abstract\s+)?class\s+(\w+)|(?m)enum\s+(\w+)").ok()
}
};

let type_names: std::collections::HashSet<String> = match type_pattern {
Some(re) => re
.captures_iter(content)
.filter_map(|caps| {
caps.get(1)
.or_else(|| caps.get(2))
.map(|m| m.as_str().to_string())
})
.collect(),
None => return symbols.to_vec(),
};

symbols
.iter()
.filter(|s| type_names.contains(s.as_str()))
.cloned()
.collect()
}

/// Resolve a TypeScript/JavaScript relative import to file content.
/// Tries common extensions: .ts, .tsx, .js, .jsx, /index.ts, /index.js
fn resolve_ts_import(base_dir: &Path, import_path: &str) -> Option<String> {
Expand Down Expand Up @@ -86,7 +170,25 @@ fn resolve_ts_import(base_dir: &Path, import_path: &str) -> Option<String> {
None
}

/// Check if a file is a test file based on language conventions.
/// Well-known test directory names (case-insensitive check).
const TEST_DIR_NAMES: &[&str] = &[
"tests",
"test",
"__tests__",
"spec",
"specs",
"testing",
"uitests",
"unittests",
"integrationtests",
"testcases",
"fixtures",
"mocks",
"stubs",
"fakes",
];

/// Check if a file is a test file based on language conventions and path.
pub fn is_test_file(file_path: &Path) -> bool {
let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");

Expand All @@ -97,12 +199,23 @@ pub fn is_test_file(file_path: &Path) -> bool {

let name = file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");

// Check filename patterns
for pattern in lang.test_patterns() {
if name.ends_with(pattern) || name.starts_with(pattern) {
return true;
}
}

// Check if any ancestor directory is a test directory
for component in file_path.components() {
if let std::path::Component::Normal(dir) = component {
let dir_lower = dir.to_string_lossy().to_lowercase();
if TEST_DIR_NAMES.contains(&dir_lower.as_str()) {
return true;
}
}
}

false
}

Expand Down
14 changes: 13 additions & 1 deletion src/exports/typescript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,19 @@ pub fn extract_exports_with_resolver(
if let Some(name) = caps.get(1).or_else(|| caps.get(2)) {
let n = name.as_str();
// Skip keyword-like default exports (e.g. `export default new ...`)
if !["new", "function", "class", "abstract", "async", "true", "false", "null", "undefined"].contains(&n) {
if ![
"new",
"function",
"class",
"abstract",
"async",
"true",
"false",
"null",
"undefined",
]
.contains(&n)
{
symbols.push(n.to_string());
}
}
Expand Down
Loading
Loading