code-moniker 0.2.0

Standalone CLI / linter for the code-moniker symbol graph: per-file probe, directory summary, project-wide architecture rules.
Documentation
use std::path::{Path, PathBuf};

use code_moniker_core::lang::ts::PathAlias;
use serde::Deserialize;

#[derive(Debug, Clone, Default)]
pub struct TsResolution {
	pub aliases: Vec<PathAlias>,
}

#[derive(Deserialize)]
struct RawTsConfig {
	#[serde(rename = "compilerOptions", default)]
	compiler_options: Option<RawCompilerOptions>,
	#[serde(default)]
	references: Vec<RawReference>,
}

#[derive(Deserialize)]
struct RawCompilerOptions {
	#[serde(rename = "baseUrl", default)]
	base_url: Option<String>,
	#[serde(default)]
	paths: std::collections::BTreeMap<String, Vec<String>>,
}

#[derive(Deserialize)]
struct RawReference {
	path: String,
}

const TSCONFIG_CANDIDATES: &[&str] = &[
	"tsconfig.json",
	"tsconfig.app.json",
	"tsconfig.base.json",
	"tsconfig.web.json",
];

const SKIP_DIR_NAMES: &[&str] = &["node_modules", "target", "dist", "build", "out"];

const MAX_REFERENCES_DEPTH: usize = 3;

pub fn load(root: &Path) -> TsResolution {
	let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
	let mut aliases: Vec<PathAlias> = Vec::new();
	for entry in discover_tsconfigs(root) {
		merge_from_file(&entry, &canonical_root, &mut aliases, 0);
	}
	TsResolution { aliases }
}

fn discover_tsconfigs(root: &Path) -> Vec<PathBuf> {
	let mut out = Vec::new();
	push_tsconfigs_in(root, &mut out);
	if let Ok(entries) = std::fs::read_dir(root) {
		for entry in entries.flatten() {
			let path = entry.path();
			if path.is_dir() && !is_ignored_dir(&path) {
				push_tsconfigs_in(&path, &mut out);
			}
		}
	}
	out
}

fn push_tsconfigs_in(dir: &Path, out: &mut Vec<PathBuf>) {
	for name in TSCONFIG_CANDIDATES {
		let p = dir.join(name);
		if p.is_file() {
			out.push(p);
		}
	}
}

fn is_ignored_dir(path: &Path) -> bool {
	let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
		return false;
	};
	name.starts_with('.') || SKIP_DIR_NAMES.contains(&name)
}

fn merge_from_file(file: &Path, root: &Path, aliases: &mut Vec<PathAlias>, depth: usize) {
	if depth > MAX_REFERENCES_DEPTH {
		return;
	}
	let Ok(raw) = std::fs::read_to_string(file) else {
		return;
	};
	let stripped = strip_jsonc(&raw);
	let Ok(parsed) = serde_json::from_str::<RawTsConfig>(&stripped) else {
		return;
	};
	let file_dir = file.parent().unwrap_or(root);

	if let Some(opts) = parsed.compiler_options.as_ref() {
		let base_dir = match opts.base_url.as_deref() {
			Some(s) => file_dir.join(s),
			None => file_dir.to_path_buf(),
		};
		for (pattern, substitutions) in &opts.paths {
			let Some(first) = substitutions.first() else {
				continue;
			};
			let Some(substitution) = rebase_substitution(&base_dir, first, root) else {
				continue;
			};
			if !aliases.iter().any(|a| a.pattern == *pattern) {
				aliases.push(PathAlias {
					pattern: pattern.clone(),
					substitution,
				});
			}
		}
	}

	for r in parsed.references {
		let p = file_dir.join(&r.path);
		let resolved = if p.is_file() {
			p
		} else if p.is_dir() {
			p.join("tsconfig.json")
		} else if p.extension().is_none() {
			let with_ext = p.with_extension("json");
			if with_ext.is_file() {
				with_ext
			} else {
				continue;
			}
		} else {
			continue;
		};
		merge_from_file(&resolved, root, aliases, depth + 1);
	}
}

fn rebase_substitution(base_dir: &Path, sub: &str, root: &Path) -> Option<String> {
	let (prefix, star, suffix) = match sub.find('*') {
		Some(i) => (&sub[..i], true, &sub[i + 1..]),
		None => (sub, false, ""),
	};
	let abs_prefix = base_dir.join(prefix);
	let canonical = abs_prefix.canonicalize().unwrap_or_else(|_| {
		base_dir
			.canonicalize()
			.unwrap_or_else(|_| base_dir.to_path_buf())
			.join(prefix)
	});
	let rel = canonical.strip_prefix(root).ok()?;
	let rel_str = rel.to_string_lossy();
	let mut out = String::from("./");
	out.push_str(&rel_str);
	if star {
		if !out.ends_with('/') && !rel_str.is_empty() {
			out.push('/');
		}
		out.push('*');
		out.push_str(suffix);
	}
	Some(out)
}

fn strip_jsonc(src: &str) -> String {
	let bytes = src.as_bytes();
	let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
	let mut i = 0;
	while i < bytes.len() {
		let b = bytes[i];
		if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
			while i < bytes.len() && bytes[i] != b'\n' {
				i += 1;
			}
		} else if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
			i += 2;
			while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
				i += 1;
			}
			i = (i + 2).min(bytes.len());
		} else if b == b'"' {
			out.push(b);
			i += 1;
			while i < bytes.len() && bytes[i] != b'"' {
				if bytes[i] == b'\\' && i + 1 < bytes.len() {
					out.push(bytes[i]);
					out.push(bytes[i + 1]);
					i += 2;
				} else {
					out.push(bytes[i]);
					i += 1;
				}
			}
			if i < bytes.len() {
				out.push(bytes[i]);
				i += 1;
			}
		} else {
			out.push(b);
			i += 1;
		}
	}
	String::from_utf8(out).unwrap_or_else(|_| src.to_string())
}

#[cfg(test)]
mod tests {
	use super::*;
	use std::fs;
	use tempfile::tempdir;

	#[test]
	fn load_picks_aliases_from_root_tsconfig() {
		let tmp = tempdir().unwrap();
		fs::write(
			tmp.path().join("tsconfig.json"),
			r#"{"compilerOptions": {"paths": {"@/*": ["./src/*"]}}}"#,
		)
		.unwrap();
		let r = load(tmp.path());
		assert_eq!(r.aliases.len(), 1);
		assert_eq!(r.aliases[0].pattern, "@/*");
		assert_eq!(r.aliases[0].substitution, "./src/*");
	}

	#[test]
	fn load_picks_aliases_from_nested_tsconfig() {
		let tmp = tempdir().unwrap();
		fs::create_dir_all(tmp.path().join("web/src")).unwrap();
		fs::write(
			tmp.path().join("web/tsconfig.app.json"),
			r#"{"compilerOptions": {"paths": {"@/*": ["./src/*"]}}}"#,
		)
		.unwrap();
		let r = load(tmp.path());
		let pattern_hit = r
			.aliases
			.iter()
			.any(|a| a.pattern == "@/*" && a.substitution.ends_with("web/src/*"));
		assert!(
			pattern_hit,
			"alias from nested tsconfig must be rebased to project root: {:?}",
			r.aliases
		);
	}

	#[test]
	fn load_strips_jsonc_comments() {
		let tmp = tempdir().unwrap();
		fs::write(
			tmp.path().join("tsconfig.json"),
			"{\n  // a comment\n  \"compilerOptions\": { \"paths\": { \"@/*\": [\"./src/*\"] } } /* trailing */\n}",
		)
		.unwrap();
		let r = load(tmp.path());
		assert_eq!(r.aliases.len(), 1);
	}

	#[test]
	fn load_empty_when_no_tsconfig() {
		let tmp = tempdir().unwrap();
		let r = load(tmp.path());
		assert!(r.aliases.is_empty());
	}

	#[test]
	fn load_ignores_node_modules() {
		let tmp = tempdir().unwrap();
		fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
		fs::write(
			tmp.path().join("node_modules/foo/tsconfig.json"),
			r#"{"compilerOptions": {"paths": {"!polluted/*": ["./*"]}}}"#,
		)
		.unwrap();
		let r = load(tmp.path());
		assert!(
			r.aliases.iter().all(|a| a.pattern != "!polluted/*"),
			"node_modules tsconfigs must not pollute aliases: {:?}",
			r.aliases
		);
	}

	#[test]
	fn strip_jsonc_preserves_utf8_multibyte() {
		let src = "{ \"k\": \"é à\" } // 中文";
		let out = strip_jsonc(src);
		assert!(out.contains("é à"), "UTF-8 multibyte preserved: {out:?}");
	}
}