; }
+ ";
+
+ let usages = analyze_temp_source(path, source);
+
+ assert!(usages.iter().any(|usage| usage.tag_name == "Plain"));
+ }
+
+ #[test]
+ fn private_fallback_helpers_cover_none_and_lowercase_paths() {
+ let analysis = FileAnalysis {
+ export_references: Vec::new(),
+ imports: BTreeMap::new(),
+ jsx_tags: Vec::new(),
+ local_components: BTreeMap::new(),
+ own_component_kind: Kind::Server,
+ type_identifiers: Vec::new(),
+ };
+ assert!(
+ check_imported_component(&analysis, &HashMap::new(), &HashMap::new(), "Missing")
+ .is_none()
+ );
+
+ let mut imports = BTreeMap::new();
+ imports.insert(
+ "Missing".to_string(),
+ ImportEntry {
+ export_name: "Missing".to_string(),
+ ranges: Vec::new(),
+ source: "./missing".to_string(),
+ },
+ );
+ let analysis_with_import = FileAnalysis {
+ export_references: Vec::new(),
+ imports,
+ jsx_tags: Vec::new(),
+ local_components: BTreeMap::new(),
+ own_component_kind: Kind::Server,
+ type_identifiers: Vec::new(),
+ };
+ assert!(
+ check_imported_component(
+ &analysis_with_import,
+ &HashMap::new(),
+ &HashMap::new(),
+ "Missing"
+ )
+ .is_none()
+ );
+
+ let mut imports = BTreeMap::new();
+ imports.insert(
+ "Missing".to_string(),
+ ImportEntry {
+ export_name: "Missing".to_string(),
+ ranges: Vec::new(),
+ source: "./missing".to_string(),
+ },
+ );
+ let analysis = FileAnalysis {
+ export_references: Vec::new(),
+ imports,
+ jsx_tags: Vec::new(),
+ local_components: BTreeMap::new(),
+ own_component_kind: Kind::Server,
+ type_identifiers: Vec::new(),
+ };
+ let mut resolved_paths = HashMap::new();
+ let missing_path = PathBuf::from("/tmp/missing.tsx");
+ resolved_paths.insert("Missing".to_string(), missing_path.clone());
+ assert!(
+ check_imported_component(&analysis, &resolved_paths, &HashMap::new(), "Missing")
+ .is_none()
+ );
+
+ let mapper = Utf16Mapper::new("lower");
+ let mut ranges = Vec::new();
+ let mut locals = BTreeMap::new();
+ register_component(
+ "lower",
+ Span::new(0, 5),
+ Span::new(0, 5),
+ &mapper,
+ Kind::Server,
+ &mut ranges,
+ &mut locals,
+ );
+ assert!(ranges.is_empty());
+ assert!(locals.is_empty());
+ }
+
+ #[rstest]
+ #[case::collapses_current_directory_segments("./a", "a")]
+ #[case::preserves_orphan_parent_segments_nested("../x", "../x")]
+ #[case::preserves_orphan_parent_segments_parent("..", "..")]
+ fn lexical_normalize_cases(#[case] input: &str, #[case] expected: &str) {
+ // A leading `.` yields a `Component::CurDir` (interior `.` is already
+ // collapsed by `Path::components()`), exercising the CurDir arm.
+ assert_eq!(lexical_normalize(Path::new(input)), PathBuf::from(expected));
+ }
+
+ #[test]
+ fn normalize_for_compare_lowercases_uppercase_drive_letter() {
+ assert_eq!(normalize_for_compare(Path::new("C:/Foo/Bar")), "c:/Foo/Bar");
+ }
+
+ #[test]
+ fn to_posix_relative_uses_root_prefix_without_extra_separator() {
+ assert_eq!(
+ to_posix_relative(Path::new("/"), Path::new("/project/Page.tsx")),
+ "project/Page.tsx"
+ );
+ }
+
+ #[test]
+ fn direct_export_extraction_helpers_cover_remaining_declaration_shapes() {
+ let project = TempProject::new();
+ let file = project.write(
+ "helpers.tsx",
+ r"
+ function Fn(){ return null; }
+ class Cls { render(){ return null; } }
+ export function ExportedFn(){ return null; }
+ export class ExportedCls { render(){ return null; } }
+ export const ExportedVar = () => null, lower = () => null, MissingInit;
+ const NamedDefaultClass = class { render(){ return null; } };
+ export default class NamedDefault { render(){ return null; } }
+ export { lower as lower } from './other';
+ export default notMemo(() => null);
+ ",
+ );
+ let source = fs::read_to_string(&file).expect("read helpers");
+ let (names, _, _) = extract_file_component_exports(&file, &source);
+ assert!(names.contains("ExportedFn"));
+ assert!(names.contains("ExportedCls"));
+ assert!(names.contains("ExportedVar"));
+ assert!(names.contains("NamedDefault"));
+
+ let allocator = Allocator::default();
+ let parsed = Parser::new(
+ &allocator,
+ &source,
+ SourceType::from_path(&file).expect("tsx source type"),
+ )
+ .parse();
+ let mut local_names = BTreeSet::from([
+ "Fn".to_string(),
+ "Cls".to_string(),
+ "ExportedFn".to_string(),
+ "ExportedCls".to_string(),
+ "ExportedVar".to_string(),
+ ]);
+ let mut component_names = BTreeSet::new();
+
+ for statement in &parsed.program.body {
+ if let Statement::ExportNamedDeclaration(export_decl) = statement
+ && let Some(declaration) = &export_decl.declaration
+ {
+ add_exported_declaration_names(
+ declaration,
+ true,
+ &local_names,
+ &mut component_names,
+ );
+ }
+ if let Statement::FunctionDeclaration(function) = statement {
+ add_local_function_name(function, &mut local_names);
+ }
+ if let Statement::ClassDeclaration(class_decl) = statement {
+ add_local_class_name(class_decl, &mut local_names);
+ }
+ }
+
+ assert!(component_names.contains("ExportedFn"));
+ assert!(component_names.contains("ExportedCls"));
+ assert!(component_names.contains("ExportedVar"));
+ assert!(component_names.contains("default"));
+ }
+
+ #[test]
+ fn direct_anonymous_default_declarations_cover_default_fallbacks() {
+ let anon_allocator = Allocator::default();
+ let anon_source = "export default function(){ return null; } export default class { render(){ return null; } }";
+ let anon_parsed = Parser::new(
+ &anon_allocator,
+ anon_source,
+ SourceType::from_path(Path::new("anon.tsx")).expect("tsx source type"),
+ )
+ .parse();
+ let mut anon_component_names = BTreeSet::new();
+ for statement in &anon_parsed.program.body {
+ if let Statement::ExportDefaultDeclaration(export_decl) = statement {
+ match &export_decl.declaration {
+ ExportDefaultDeclarationKind::FunctionDeclaration(function) => {
+ let declaration =
+ Declaration::FunctionDeclaration(function.clone_in(&anon_allocator));
+ add_exported_declaration_names(
+ &declaration,
+ true,
+ &BTreeSet::new(),
+ &mut anon_component_names,
+ );
+ }
+ ExportDefaultDeclarationKind::ClassDeclaration(class_decl) => {
+ let declaration =
+ Declaration::ClassDeclaration(class_decl.clone_in(&anon_allocator));
+ add_exported_declaration_names(
+ &declaration,
+ true,
+ &BTreeSet::new(),
+ &mut anon_component_names,
+ );
+ }
+ _ => {}
+ }
+ }
+ }
+ assert!(anon_component_names.contains("default"));
+ }
+
+ #[test]
+ fn direct_pattern_declarations_cover_skipped_export_bindings() {
+ let pattern_allocator = Allocator::default();
+ let pattern_source = "const [LocalSkip] = []; export const [ExportSkip] = []; export enum ExportedEnum { A }";
+ let pattern_parsed = Parser::new(
+ &pattern_allocator,
+ pattern_source,
+ SourceType::from_path(Path::new("pattern.tsx")).expect("tsx source type"),
+ )
+ .parse();
+ let mut local_component_names = BTreeSet::new();
+ let mut pattern_component_names = BTreeSet::new();
+ let mapper = Utf16Mapper::new(pattern_source);
+ let mut async_components = HashSet::new();
+ let mut component_ranges = Vec::new();
+ let mut local_components = BTreeMap::new();
+ let mut type_identifiers = Vec::new();
+ for statement in &pattern_parsed.program.body {
+ if let Statement::VariableDeclaration(variable_decl) = statement {
+ add_local_variable_names(variable_decl, &mut local_component_names);
+ }
+ if let Statement::ExportNamedDeclaration(export_decl) = statement
+ && let Some(declaration) = &export_decl.declaration
+ {
+ add_exported_declaration_names(
+ declaration,
+ false,
+ &local_component_names,
+ &mut pattern_component_names,
+ );
+ process_exported_declaration(
+ declaration,
+ &mapper,
+ Kind::Server,
+ &mut async_components,
+ &mut component_ranges,
+ &mut local_components,
+ &mut type_identifiers,
+ );
+ }
+ }
+ assert!(local_component_names.is_empty());
+ assert!(pattern_component_names.is_empty());
+ }
+
+ #[test]
+ fn direct_default_class_expression_branch_registers_named_expression() {
+ let source = "const X = class NamedExpression { render(){ return null; } };";
+ let allocator = Allocator::default();
+ let parsed = Parser::new(
+ &allocator,
+ source,
+ SourceType::from_path(Path::new("class-expression.tsx")).expect("tsx source type"),
+ )
+ .parse();
+ let mapper = Utf16Mapper::new(source);
+ let mut async_components = HashSet::new();
+ let mut component_ranges = Vec::new();
+ let mut export_references = Vec::new();
+ let mut local_components = BTreeMap::new();
+
+ let Statement::VariableDeclaration(variable_decl) = &parsed.program.body[0] else {
+ panic!("expected variable declaration");
+ };
+ let Some(Expression::ClassExpression(class_expr)) = &variable_decl.declarations[0].init
+ else {
+ panic!("expected class expression");
+ };
+ let declaration =
+ ExportDefaultDeclarationKind::ClassExpression(class_expr.clone_in(&allocator));
+ process_export_default_declaration(
+ &declaration,
+ &mapper,
+ Kind::Server,
+ &mut async_components,
+ &mut component_ranges,
+ &mut export_references,
+ &mut local_components,
+ );
+
+ assert!(local_components.contains_key("NamedExpression"));
+ }
+
+ #[test]
+ fn direct_visitor_helper_covers_function_expression_tracking_guard() {
+ let source = "const inner = function named(){ return null; };";
+ let allocator = Allocator::default();
+ let parsed = Parser::new(
+ &allocator,
+ source,
+ SourceType::from_path(Path::new("expr.tsx")).expect("tsx source type"),
+ )
+ .parse();
+ let mapper = Utf16Mapper::new(source);
+
+ let Statement::VariableDeclaration(variable_decl) = &parsed.program.body[0] else {
+ panic!("expected variable declaration");
+ };
+ let Some(Expression::FunctionExpression(function)) = &variable_decl.declarations[0].init
+ else {
+ panic!("expected function expression");
+ };
+
+ let mut funcs = BTreeMap::new();
+ funcs.insert("Component".to_string(), BTreeMap::new());
+ let mut refs = BTreeMap::new();
+ refs.insert("Component".to_string(), Vec::new());
+ let mut type_identifiers = Vec::new();
+ let mut collector = SourceElementCollector {
+ component_by_span: HashMap::new(),
+ components_with_inline_fn: Some(HashSet::new()),
+ current_component: Some("Component".to_string()),
+ current_component_tracked: true,
+ jsx_tags: Vec::new(),
+ mapper: &mapper,
+ per_component_funcs: Some(funcs),
+ per_component_refs: Some(refs),
+ source_text: source,
+ type_identifiers: &mut type_identifiers,
+ type_literal_depth: 0,
+ };
+
+ collector.track_function_declaration(function);
+ assert!(collector.per_component_funcs.expect("func map")["Component"].is_empty());
+ }
+}
diff --git a/packages/core/src/bin/emit-canonical.rs b/packages/core/src/bin/emit-canonical.rs
new file mode 100644
index 0000000..e45bca4
--- /dev/null
+++ b/packages/core/src/bin/emit-canonical.rs
@@ -0,0 +1,22 @@
+//! Emit canonical analyzer JSON for a single source file.
+//!
+//! Usage: `emit-canonical
`
+//!
+//! Prints the canonical JSON (CONTRACT §3.4) for the file at the given path,
+//! with `sourceFilePath` relativized to the file's parent directory. Used by
+//! the differential fuzzer to compare the Rust engine against the TS oracle.
+
+use std::path::Path;
+use std::process::ExitCode;
+
+fn main() -> ExitCode {
+ let Some(path) = std::env::args().nth(1) else {
+ eprintln!("usage: emit-canonical ");
+ return ExitCode::FAILURE;
+ };
+ print!(
+ "{}",
+ rcl_core::analyzer::analyze_path_canonical(Path::new(&path))
+ );
+ ExitCode::SUCCESS
+}
diff --git a/packages/core/src/canonical.rs b/packages/core/src/canonical.rs
new file mode 100644
index 0000000..13ac91b
--- /dev/null
+++ b/packages/core/src/canonical.rs
@@ -0,0 +1,488 @@
+//! Canonical compact-JSON serializer (CONTRACT §3.3/§3.4): dedup, total order,
+//! fixed field order, raw UTF-8, integer offsets. Operates on [`crate::Usage`].
+
+use crate::Usage;
+
+/// Normalize a path to canonical form: forward slashes + lowercased drive letter (CONTRACT §4).
+#[must_use]
+pub fn normalize_path(path: &str) -> String {
+ let mut normalized = path.replace('\\', "/");
+ if normalized.len() >= 2 && normalized.as_bytes()[1] == b':' {
+ let drive = normalized.as_bytes()[0];
+ if (drive as char).is_ascii_uppercase() {
+ let mut chars = normalized.chars();
+ if let Some(first) = chars.next() {
+ normalized = first.to_lowercase().to_string() + chars.as_str();
+ }
+ }
+ }
+ normalized
+}
+
+/// Canonical compact-JSON serialization (CONTRACT §3.4): dedup by full tuple,
+/// total order per §3.3, fixed field order kind/tagName/sourceFilePath/ranges.
+/// Mutates `usages` (sorts/dedups). Byte-identical to the TS oracle.
+#[must_use]
+pub fn serialize_canonical(usages: &mut [Usage]) -> String {
+ // Normalize paths and sort ranges within each usage
+ for usage in usages.iter_mut() {
+ usage.source_file_path = normalize_path(&usage.source_file_path);
+ usage.ranges.sort_by(|a, b| {
+ if a.start == b.start {
+ a.end.cmp(&b.end)
+ } else {
+ a.start.cmp(&b.start)
+ }
+ });
+ }
+
+ // Dedup by full tuple (kind, tagName, sourceFilePath, ranges)
+ let mut seen = std::collections::HashSet::new();
+ let mut deduped = Vec::new();
+ for usage in usages.iter() {
+ let key = serialize_usage(usage);
+ if seen.insert(key) {
+ deduped.push(usage.clone());
+ }
+ }
+
+ // Sort by canonical order
+ deduped.sort_by(compare_canonical);
+
+ // Serialize to compact JSON
+ let mut out = String::from("[");
+ for (i, usage) in deduped.iter().enumerate() {
+ if i > 0 {
+ out.push(',');
+ }
+ out.push_str(&serialize_usage(usage));
+ }
+ out.push(']');
+ out
+}
+
+/// Serialize a single usage to compact JSON.
+fn serialize_usage(usage: &Usage) -> String {
+ use std::fmt::Write;
+
+ let mut ranges_json = String::from("[");
+ for (i, range) in usage.ranges.iter().enumerate() {
+ if i > 0 {
+ ranges_json.push(',');
+ }
+ let _ = write!(
+ ranges_json,
+ r#"{{"start":{},"end":{}}}"#,
+ range.start, range.end
+ );
+ }
+ ranges_json.push(']');
+
+ format!(
+ r#"{{"kind":{},"tagName":{},"sourceFilePath":{},"ranges":{}}}"#,
+ serde_json::to_string(usage.kind.as_str()).unwrap(),
+ serde_json::to_string(&usage.tag_name).unwrap(),
+ serde_json::to_string(&usage.source_file_path).unwrap(),
+ ranges_json
+ )
+}
+
+/// Compare two usages for canonical ordering (CONTRACT §3.3).
+fn compare_canonical(a: &Usage, b: &Usage) -> std::cmp::Ordering {
+ use std::cmp::Ordering;
+
+ // 1. ranges[0].start
+ let a_first = a.ranges.first();
+ let b_first = b.ranges.first();
+ match (a_first, b_first) {
+ (Some(af), Some(bf)) => {
+ if af.start != bf.start {
+ return af.start.cmp(&bf.start);
+ }
+ // 2. ranges[0].end
+ if af.end != bf.end {
+ return af.end.cmp(&bf.end);
+ }
+ }
+ (Some(_), None) => return Ordering::Greater,
+ (None, Some(_)) => return Ordering::Less,
+ (None, None) => {}
+ }
+
+ // 3. kind ("client" < "server")
+ if a.kind != b.kind {
+ return a.kind.cmp(&b.kind);
+ }
+
+ // 4. tagName (lexicographic)
+ if a.tag_name != b.tag_name {
+ return a.tag_name.cmp(&b.tag_name);
+ }
+
+ // 5. sourceFilePath (lexicographic)
+ if a.source_file_path != b.source_file_path {
+ return a.source_file_path.cmp(&b.source_file_path);
+ }
+
+ // 6. remaining ranges pairwise, then by length
+ let shared = a.ranges.len().min(b.ranges.len());
+ for i in 1..shared {
+ let a_range = &a.ranges[i];
+ let b_range = &b.ranges[i];
+ if a_range.start != b_range.start {
+ return a_range.start.cmp(&b_range.start);
+ }
+ if a_range.end != b_range.end {
+ return a_range.end.cmp(&b_range.end);
+ }
+ }
+ a.ranges.len().cmp(&b.ranges.len())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{Kind, Range};
+ use rstest::rstest;
+
+ #[rstest]
+ #[case::test_normalize_path_windows_uppercase_drive("C:\\a\\b\\Page.tsx", "c:/a/b/Page.tsx")]
+ #[case::test_normalize_path_posix("/home/user/Page.tsx", "/home/user/Page.tsx")]
+ #[case::test_normalize_path_mixed_separators("D:\\Mixed/Path.tsx", "d:/Mixed/Path.tsx")]
+ fn test_normalize_path_cases(#[case] input: &str, #[case] expected: &str) {
+ assert_eq!(normalize_path(input), expected);
+ }
+
+ #[test]
+ fn test_serialize_canonical_single_usage() {
+ let mut usages = vec![Usage {
+ kind: Kind::Client,
+ tag_name: "A".to_string(),
+ source_file_path: "/proj/A.tsx".to_string(),
+ ranges: vec![Range { start: 1, end: 7 }],
+ }];
+ let result = serialize_canonical(&mut usages);
+ assert_eq!(
+ result,
+ r#"[{"kind":"client","tagName":"A","sourceFilePath":"/proj/A.tsx","ranges":[{"start":1,"end":7}]}]"#
+ );
+ }
+
+ #[test]
+ fn test_serialize_canonical_non_ascii_path() {
+ let mut usages = vec![Usage {
+ kind: Kind::Client,
+ tag_name: "A".to_string(),
+ source_file_path: "/프로젝트/A.tsx".to_string(),
+ ranges: vec![Range { start: 1, end: 7 }],
+ }];
+ let result = serialize_canonical(&mut usages);
+ // Should output raw UTF-8, not \u-escaped
+ assert_eq!(
+ result,
+ r#"[{"kind":"client","tagName":"A","sourceFilePath":"/프로젝트/A.tsx","ranges":[{"start":1,"end":7}]}]"#
+ );
+ }
+
+ #[test]
+ fn test_serialize_canonical_multiple_usages_in_order() {
+ let mut usages = vec![
+ Usage {
+ kind: Kind::Server,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/A.tsx".to_string(),
+ ranges: vec![Range { start: 1, end: 7 }],
+ },
+ Usage {
+ kind: Kind::Client,
+ tag_name: "B".to_string(),
+ source_file_path: "/p/B.tsx".to_string(),
+ ranges: vec![Range { start: 24, end: 30 }],
+ },
+ ];
+ let result = serialize_canonical(&mut usages);
+ assert_eq!(
+ result,
+ r#"[{"kind":"server","tagName":"A","sourceFilePath":"/p/A.tsx","ranges":[{"start":1,"end":7}]},{"kind":"client","tagName":"B","sourceFilePath":"/p/B.tsx","ranges":[{"start":24,"end":30}]}]"#
+ );
+ }
+
+ #[test]
+ fn test_serialize_canonical_dedup() {
+ let mut usages = vec![
+ Usage {
+ kind: Kind::Client,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/A.tsx".to_string(),
+ ranges: vec![Range { start: 1, end: 7 }],
+ },
+ Usage {
+ kind: Kind::Client,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/A.tsx".to_string(),
+ ranges: vec![Range { start: 1, end: 7 }],
+ },
+ ];
+ let result = serialize_canonical(&mut usages);
+ // Should only have one usage
+ assert_eq!(
+ result,
+ r#"[{"kind":"client","tagName":"A","sourceFilePath":"/p/A.tsx","ranges":[{"start":1,"end":7}]}]"#
+ );
+ }
+
+ #[test]
+ fn test_serialize_canonical_ranges_sorted_within_usage() {
+ let mut usages = vec![Usage {
+ kind: Kind::Server,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/A.tsx".to_string(),
+ ranges: vec![Range { start: 10, end: 12 }, Range { start: 1, end: 7 }],
+ }];
+ let result = serialize_canonical(&mut usages);
+ assert_eq!(
+ result,
+ r#"[{"kind":"server","tagName":"A","sourceFilePath":"/p/A.tsx","ranges":[{"start":1,"end":7},{"start":10,"end":12}]}]"#
+ );
+ }
+
+ #[test]
+ fn test_serialize_canonical_ranges_with_same_start_sorted_by_end() {
+ let mut usages = vec![Usage {
+ kind: Kind::Server,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/A.tsx".to_string(),
+ ranges: vec![Range { start: 1, end: 7 }, Range { start: 1, end: 3 }],
+ }];
+ let result = serialize_canonical(&mut usages);
+ assert_eq!(
+ result,
+ r#"[{"kind":"server","tagName":"A","sourceFilePath":"/p/A.tsx","ranges":[{"start":1,"end":3},{"start":1,"end":7}]}]"#
+ );
+ }
+
+ #[test]
+ fn test_compare_tie_break_first_range_end() {
+ let mut usages = vec![
+ Usage {
+ kind: Kind::Server,
+ tag_name: "B".to_string(),
+ source_file_path: "/p/X.tsx".to_string(),
+ ranges: vec![Range { start: 1, end: 9 }],
+ },
+ Usage {
+ kind: Kind::Server,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/X.tsx".to_string(),
+ ranges: vec![Range { start: 1, end: 7 }],
+ },
+ ];
+ let result = serialize_canonical(&mut usages);
+ // A (end=7) should come before B (end=9)
+ assert!(result.contains(r#""tagName":"A""#));
+ let a_pos = result.find(r#""tagName":"A""#).unwrap();
+ let b_pos = result.find(r#""tagName":"B""#).unwrap();
+ assert!(a_pos < b_pos);
+ }
+
+ #[test]
+ fn test_compare_tie_break_kind() {
+ let mut usages = vec![
+ Usage {
+ kind: Kind::Server,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/X.tsx".to_string(),
+ ranges: vec![Range { start: 5, end: 6 }],
+ },
+ Usage {
+ kind: Kind::Client,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/X.tsx".to_string(),
+ ranges: vec![Range { start: 5, end: 6 }],
+ },
+ ];
+ let result = serialize_canonical(&mut usages);
+ // client should come before server
+ let client_pos = result.find(r#""kind":"client""#).unwrap();
+ let server_pos = result.find(r#""kind":"server""#).unwrap();
+ assert!(client_pos < server_pos);
+ }
+
+ #[test]
+ fn test_compare_tie_break_tag_name() {
+ let mut usages = vec![
+ Usage {
+ kind: Kind::Server,
+ tag_name: "Bbb".to_string(),
+ source_file_path: "/p/X.tsx".to_string(),
+ ranges: vec![Range { start: 5, end: 6 }],
+ },
+ Usage {
+ kind: Kind::Server,
+ tag_name: "Aaa".to_string(),
+ source_file_path: "/p/X.tsx".to_string(),
+ ranges: vec![Range { start: 5, end: 6 }],
+ },
+ ];
+ let result = serialize_canonical(&mut usages);
+ // Aaa should come before Bbb
+ let aaa_pos = result.find(r#""tagName":"Aaa""#).unwrap();
+ let bbb_pos = result.find(r#""tagName":"Bbb""#).unwrap();
+ assert!(aaa_pos < bbb_pos);
+ }
+
+ #[test]
+ fn test_compare_tie_break_source_file_path() {
+ let mut usages = vec![
+ Usage {
+ kind: Kind::Server,
+ tag_name: "X".to_string(),
+ source_file_path: "/p/B.tsx".to_string(),
+ ranges: vec![Range { start: 5, end: 6 }],
+ },
+ Usage {
+ kind: Kind::Server,
+ tag_name: "X".to_string(),
+ source_file_path: "/p/A.tsx".to_string(),
+ ranges: vec![Range { start: 5, end: 6 }],
+ },
+ ];
+ let result = serialize_canonical(&mut usages);
+ // /p/A.tsx should come before /p/B.tsx
+ let a_pos = result.find(r#""/p/A.tsx""#).unwrap();
+ let b_pos = result.find(r#""/p/B.tsx""#).unwrap();
+ assert!(a_pos < b_pos);
+ }
+
+ #[test]
+ fn test_compare_tie_break_subsequent_range_and_count() {
+ let mut usages = vec![
+ Usage {
+ kind: Kind::Server,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/X.tsx".to_string(),
+ ranges: vec![Range { start: 5, end: 9 }, Range { start: 10, end: 12 }],
+ },
+ Usage {
+ kind: Kind::Server,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/X.tsx".to_string(),
+ ranges: vec![Range { start: 5, end: 6 }, Range { start: 7, end: 9 }],
+ },
+ Usage {
+ kind: Kind::Server,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/X.tsx".to_string(),
+ ranges: vec![Range { start: 5, end: 6 }],
+ },
+ ];
+ let result = serialize_canonical(&mut usages);
+ // Order should be: 1 range (5-6), 2 ranges (5-6, 7-9), 2 ranges (5-9, 10-12)
+ let one_range = r#""ranges":[{"start":5,"end":6}]"#;
+ let two_ranges_early = r#""ranges":[{"start":5,"end":6},{"start":7,"end":9}]"#;
+ let two_ranges_late = r#""ranges":[{"start":5,"end":9},{"start":10,"end":12}]"#;
+
+ let pos1 = result.find(one_range).unwrap();
+ let pos2 = result.find(two_ranges_early).unwrap();
+ let pos3 = result.find(two_ranges_late).unwrap();
+ assert!(pos1 < pos2 && pos2 < pos3);
+ }
+
+ #[test]
+ fn test_compare_remaining_ranges_loop() {
+ let mut usages = vec![
+ Usage {
+ kind: Kind::Server,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/X.tsx".to_string(),
+ ranges: vec![Range { start: 5, end: 6 }, Range { start: 8, end: 9 }],
+ },
+ Usage {
+ kind: Kind::Server,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/X.tsx".to_string(),
+ ranges: vec![Range { start: 5, end: 6 }, Range { start: 7, end: 9 }],
+ },
+ ];
+ let result = serialize_canonical(&mut usages);
+ // Second range start 7 < 8, so second usage comes first
+ let early = r#""ranges":[{"start":5,"end":6},{"start":7,"end":9}]"#;
+ let late = r#""ranges":[{"start":5,"end":6},{"start":8,"end":9}]"#;
+ let early_pos = result.find(early).unwrap();
+ let late_pos = result.find(late).unwrap();
+ assert!(early_pos < late_pos);
+ }
+
+ #[test]
+ fn test_compare_empty_ranges() {
+ let mut usages = vec![
+ Usage {
+ kind: Kind::Server,
+ tag_name: "Z".to_string(),
+ source_file_path: "/p/X.tsx".to_string(),
+ ranges: vec![Range { start: 1, end: 2 }],
+ },
+ Usage {
+ kind: Kind::Server,
+ tag_name: "Z".to_string(),
+ source_file_path: "/p/X.tsx".to_string(),
+ ranges: vec![],
+ },
+ Usage {
+ kind: Kind::Client,
+ tag_name: "Z".to_string(),
+ source_file_path: "/p/X.tsx".to_string(),
+ ranges: vec![],
+ },
+ ];
+ let result = serialize_canonical(&mut usages);
+ // Empty ranges sort before ranged ones, then by kind (client < server)
+ let empty_client =
+ r#"{"kind":"client","tagName":"Z","sourceFilePath":"/p/X.tsx","ranges":[]}"#;
+ let empty_server =
+ r#"{"kind":"server","tagName":"Z","sourceFilePath":"/p/X.tsx","ranges":[]}"#;
+ let ranged = r#"{"kind":"server","tagName":"Z","sourceFilePath":"/p/X.tsx","ranges":[{"start":1,"end":2}]}"#;
+
+ let client_pos = result.find(empty_client).unwrap();
+ let server_pos = result.find(empty_server).unwrap();
+ let ranged_pos = result.find(ranged).unwrap();
+ assert!(client_pos < server_pos && server_pos < ranged_pos);
+ }
+
+ #[test]
+ fn test_compare_canonical_direct_empty_and_second_range_end_branches() {
+ let ranged = Usage {
+ kind: Kind::Server,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/A.tsx".to_string(),
+ ranges: vec![Range { start: 1, end: 2 }],
+ };
+ let empty = Usage {
+ kind: Kind::Server,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/A.tsx".to_string(),
+ ranges: vec![],
+ };
+ assert_eq!(
+ compare_canonical(&ranged, &empty),
+ std::cmp::Ordering::Greater
+ );
+
+ let short_second = Usage {
+ kind: Kind::Server,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/A.tsx".to_string(),
+ ranges: vec![Range { start: 1, end: 2 }, Range { start: 3, end: 4 }],
+ };
+ let long_second = Usage {
+ kind: Kind::Server,
+ tag_name: "A".to_string(),
+ source_file_path: "/p/A.tsx".to_string(),
+ ranges: vec![Range { start: 1, end: 2 }, Range { start: 3, end: 5 }],
+ };
+ assert_eq!(
+ compare_canonical(&long_second, &short_second),
+ std::cmp::Ordering::Greater
+ );
+ }
+}
diff --git a/packages/core/src/directive.rs b/packages/core/src/directive.rs
new file mode 100644
index 0000000..dbb0a37
--- /dev/null
+++ b/packages/core/src/directive.rs
@@ -0,0 +1,121 @@
+//! `"use client"` directive byte-scanner (CONTRACT §5, verbatim port of
+//! `hasUseClientDirective`). Implemented in P3-W2 (Task 3).
+
+/// Returns true iff `source` (raw UTF-8 bytes) begins with a `"use client"`
+/// directive per CONTRACT §5. Byte-level; escapes are NOT interpreted.
+#[must_use]
+pub fn has_use_client_directive(source: &[u8]) -> bool {
+ let len = source.len();
+ let mut i = 0;
+
+ while i < len {
+ let ch = source[i];
+
+ // Skip whitespace/control (<=32), semicolon (59), or BOM (0xEF 0xBB 0xBF)
+ if ch <= 32 || ch == 59 {
+ i += 1;
+ continue;
+ }
+
+ // Check for UTF-8 BOM (3 bytes: EF BB BF)
+ if i == 0 && i + 3 <= len && source[0] == 0xEF && source[1] == 0xBB && source[2] == 0xBF {
+ i += 3;
+ continue;
+ }
+
+ // Line comment: // -> skip to \n
+ if ch == 47 && i + 1 < len {
+ let next = source[i + 1];
+ if next == 47 {
+ i += 2;
+ while i < len && source[i] != 10 {
+ i += 1;
+ }
+ continue;
+ }
+ // Block comment: /* -> skip to */
+ if next == 42 {
+ i += 2;
+ while i + 1 < len {
+ if source[i] == 42 && source[i + 1] == 47 {
+ i += 2;
+ break;
+ }
+ i += 1;
+ }
+ continue;
+ }
+ }
+
+ // String literal: " or '
+ if ch == 34 || ch == 39 {
+ // Check if this is "use client" or 'use client'
+ // Pattern: quote + u(117) s(115) e(101) space(32) c(99) l(108) i(105) e(101) n(110) t(116) + same quote
+ if i + 11 < len
+ && source[i + 1] == 117 // u
+ && source[i + 2] == 115 // s
+ && source[i + 3] == 101 // e
+ && source[i + 4] == 32 // space
+ && source[i + 5] == 99 // c
+ && source[i + 6] == 108 // l
+ && source[i + 7] == 105 // i
+ && source[i + 8] == 101 // e
+ && source[i + 9] == 110 // n
+ && source[i + 10] == 116 // t
+ && source[i + 11] == ch
+ // closing quote matches opening
+ {
+ return true;
+ }
+ // Skip to closing quote and continue
+ i += 1;
+ while i < len && source[i] != ch {
+ i += 1;
+ }
+ if i < len {
+ i += 1;
+ }
+ continue;
+ }
+
+ // Any other character -> not a client component
+ return false;
+ }
+
+ // End of input -> not a client component
+ false
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rstest::rstest;
+
+ #[rstest]
+ #[case::single_quote_use_client(b"'use client'".as_slice(), true)]
+ #[case::double_quote_use_client(b"\"use client\"".as_slice(), true)]
+ #[case::preceding_directive_use_strict(b"'use strict'\n'use client'".as_slice(), true)]
+ #[case::non_directive_token_first(b"const x=1\n'use client'".as_slice(), false)]
+ #[case::line_comment_then_use_client(b"// comment\n'use client'".as_slice(), true)]
+ #[case::block_comment_then_use_client(b"/* comment */'use client'".as_slice(), true)]
+ #[case::plain_export_function(b"export function F(){}".as_slice(), false)]
+ #[case::escaped_space_not_matched(b"\"use\\u0020client\"".as_slice(), false)]
+ #[case::utf8_bom_then_use_client(b"\xEF\xBB\xBF'use client'".as_slice(), true)]
+ #[case::whitespace_then_use_client(b" \n\t'use client'".as_slice(), true)]
+ #[case::semicolon_then_use_client(b";;'use client'".as_slice(), true)]
+ #[case::jsx_before_directive(b";\n'use client'".as_slice(), false)]
+ #[case::empty_file(b"".as_slice(), false)]
+ #[case::only_whitespace(b" \n\t ".as_slice(), false)]
+ #[case::use_client_not_at_start(b"const x = 'use client'".as_slice(), false)]
+ #[case::use_client_with_extra_text_after_quote(b"'use client' extra".as_slice(), true)]
+ #[case::block_comment_not_nested(b"/* /* nested */ */'use client'".as_slice(), false)]
+ #[case::multiple_line_comments(b"// comment1\n// comment2\n'use client'".as_slice(), true)]
+ #[case::long_line_comment_without_newline_scans_to_end(
+ b"// comment with no trailing newline".as_slice(),
+ false
+ )]
+ #[case::slash_that_is_not_a_comment_stops_scanning(b"/ not a comment".as_slice(), false)]
+ fn has_use_client_directive_cases(#[case] input: &[u8], #[case] expected: bool) {
+ assert_eq!(has_use_client_directive(input), expected);
+ }
+}
diff --git a/packages/core/src/lib.rs b/packages/core/src/lib.rs
new file mode 100644
index 0000000..de7deee
--- /dev/null
+++ b/packages/core/src/lib.rs
@@ -0,0 +1,51 @@
+//! Rust reimplementation of the React Component Lens analyzer.
+//!
+//! This crate must produce **byte-identical canonical output** to the
+//! TypeScript oracle (`packages/core`) per `conformance/CONTRACT.md` (v1).
+//! Positions are UTF-16 code-unit offsets, `end` exclusive.
+
+pub mod analyzer;
+pub mod canonical;
+pub mod directive;
+pub mod resolver;
+pub mod utf16;
+
+pub use analyzer::{
+ analyze_source_with_host_and_scope, analyze_source_with_host_and_scope_and_fs,
+ find_component_declaration,
+};
+
+/// Component kind. The analyzer emits only `Client` / `Server`
+/// (`unknown` is an internal TS concept never serialized).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum Kind {
+ Client,
+ Server,
+}
+
+impl Kind {
+ /// Canonical string per CONTRACT (`"client"` / `"server"`).
+ #[must_use]
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Kind::Client => "client",
+ Kind::Server => "server",
+ }
+ }
+}
+
+/// A half-open range `[start, end)` in **UTF-16 code units**.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Range {
+ pub start: u32,
+ pub end: u32,
+}
+
+/// One canonical component usage (the analyzer's output element).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Usage {
+ pub kind: Kind,
+ pub tag_name: String,
+ pub source_file_path: String,
+ pub ranges: Vec,
+}
diff --git a/packages/core/src/resolver.rs b/packages/core/src/resolver.rs
new file mode 100644
index 0000000..d1b7c56
--- /dev/null
+++ b/packages/core/src/resolver.rs
@@ -0,0 +1,237 @@
+//! Module resolution via `oxc_resolver`, matched to `ts.resolveModuleName`
+//! bundler mode (CONTRACT §6).
+//!
+//! The TypeScript oracle (`resolver.ts`) uses `ts.resolveModuleName` with
+//! `moduleResolution: "bundler"`, default options
+//! `{ allowJs, jsx:preserve, module:ESNext, target:ES2022 }`, the nearest
+//! `tsconfig.json`/`jsconfig.json` for `paths`/`baseUrl`, accepts only
+//! `.ts`/`.tsx`/`.js`/`.jsx`, and rejects `.d.ts`.
+
+use std::{
+ fs, io,
+ path::{Path, PathBuf},
+};
+
+use oxc_resolver::{
+ FileMetadata, FileSystemOs, ResolveError, ResolveOptions, ResolverGeneric, TsconfigDiscovery,
+ TsconfigOptions, TsconfigReferences,
+};
+
+pub use oxc_resolver::FileSystem;
+
+#[derive(Clone, Copy, Default)]
+pub(crate) struct NativeFileSystem;
+
+impl FileSystem for NativeFileSystem {
+ fn new() -> Self {
+ Self
+ }
+
+ fn read(&self, path: &Path) -> io::Result> {
+ fs::read(path)
+ }
+
+ fn read_to_string(&self, path: &Path) -> io::Result {
+ FileSystemOs::read_to_string(path)
+ }
+
+ fn metadata(&self, path: &Path) -> io::Result {
+ FileSystemOs::metadata(path)
+ }
+
+ fn symlink_metadata(&self, path: &Path) -> io::Result {
+ FileSystemOs::symlink_metadata(path)
+ }
+
+ fn read_link(&self, path: &Path) -> Result {
+ FileSystemOs::read_link(path)
+ }
+
+ fn canonicalize(&self, path: &Path) -> io::Result {
+ FileSystemOs::canonicalize(path)
+ }
+}
+
+const CONFIG_FILE_NAMES: [&str; 2] = ["tsconfig.json", "jsconfig.json"];
+
+/// Walk up from the importing file's directory looking for the nearest
+/// `tsconfig.json` then `jsconfig.json` (mirrors `findNearestConfigFile`).
+#[must_use]
+pub fn find_nearest_config_with_fs(from_file: &Path, fs: &F) -> Option {
+ let mut dir = from_file.parent();
+ while let Some(current) = dir {
+ for name in CONFIG_FILE_NAMES {
+ let candidate = current.join(name);
+ if fs.metadata(&candidate).is_ok_and(FileMetadata::is_file) {
+ return Some(candidate);
+ }
+ }
+ dir = current.parent();
+ }
+ None
+}
+
+fn is_rejected_dts(path: &Path) -> bool {
+ let lossy = path.to_string_lossy();
+ lossy.ends_with(".d.ts") || lossy.ends_with(".d.mts") || lossy.ends_with(".d.cts")
+}
+
+fn build_resolver_with_fs(from_file: &Path, fs: F) -> (ResolverGeneric, bool) {
+ let tsconfig = find_nearest_config_with_fs(from_file, &fs).map(|config_file| {
+ TsconfigDiscovery::Manual(TsconfigOptions {
+ config_file,
+ references: TsconfigReferences::Auto,
+ })
+ });
+ let has_manual_tsconfig = tsconfig.is_some();
+
+ (
+ ResolverGeneric::new_with_file_system(
+ fs,
+ ResolveOptions {
+ // Extension priority matches TS bundler mode: .ts > .tsx > .js > .jsx.
+ extensions: vec![".ts".into(), ".tsx".into(), ".js".into(), ".jsx".into()],
+ // `import "./foo.js"` resolves to foo.ts first (TS bundler behavior).
+ extension_alias: vec![
+ (
+ ".js".into(),
+ vec![".ts".into(), ".tsx".into(), ".js".into()],
+ ),
+ (".jsx".into(), vec![".tsx".into(), ".jsx".into()]),
+ (".mjs".into(), vec![".mts".into(), ".mjs".into()]),
+ (".cjs".into(), vec![".cts".into(), ".cjs".into()]),
+ ],
+ main_files: vec!["index".into()],
+ main_fields: vec!["module".into(), "main".into()],
+ condition_names: vec!["import".into(), "default".into()],
+ // Match TS default (does not collapse symlinks to realpath).
+ symlinks: false,
+ tsconfig,
+ ..ResolveOptions::default()
+ },
+ ),
+ has_manual_tsconfig,
+ )
+}
+
+/// Resolve an import `specifier` from `from_file` to an absolute source-file
+/// path (`.ts`/`.tsx`/`.js`/`.jsx`, `.d.ts` rejected). Returns `None` when
+/// unresolved or resolving only to a declaration file.
+#[must_use]
+pub fn resolve_import(from_file: &Path, specifier: &str) -> Option {
+ resolve_import_with_fs(from_file, specifier, FileSystemOs::new())
+}
+
+/// Resolve an import using an injected filesystem.
+#[must_use]
+pub fn resolve_import_with_fs(
+ from_file: &Path,
+ specifier: &str,
+ fs: F,
+) -> Option {
+ let (resolver, has_manual_tsconfig) = build_resolver_with_fs(from_file, fs);
+ let resolution = if has_manual_tsconfig {
+ resolver.resolve(from_file.parent()?, specifier).ok()?
+ } else {
+ resolver.resolve_file(from_file, specifier).ok()?
+ };
+ let path = resolution.into_path_buf();
+ if is_rejected_dts(&path) {
+ return None;
+ }
+ Some(path)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+ use std::path::PathBuf;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+
+ use super::*;
+
+ static NEXT_PROJECT_ID: AtomicUsize = AtomicUsize::new(0);
+
+ struct TempProject {
+ root: PathBuf,
+ }
+
+ impl TempProject {
+ fn new() -> Self {
+ let id = NEXT_PROJECT_ID.fetch_add(1, Ordering::Relaxed);
+ let root =
+ std::env::temp_dir().join(format!("rcl-core-resolver-{}-{id}", std::process::id()));
+ if root.exists() {
+ fs::remove_dir_all(&root).expect("remove stale temp project");
+ }
+ fs::create_dir_all(&root).expect("create temp project");
+ Self { root }
+ }
+
+ fn write(&self, relative: &str, text: &str) -> PathBuf {
+ let path = self.root.join(relative);
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).expect("create parent directories");
+ }
+ fs::write(&path, text).expect("write temp file");
+ path
+ }
+ }
+
+ impl Drop for TempProject {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.root);
+ }
+ }
+
+ #[test]
+ fn native_file_system_new_and_read_link_error_are_covered() {
+ let fs = NativeFileSystem::new();
+ let project = TempProject::new();
+ let file = project.write("target.ts", "export const value = 1;");
+
+ assert!(fs.canonicalize(&file).is_ok());
+ assert!(
+ fs.read_link(&PathBuf::from("react-component-lens-missing-link"))
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn rejects_declaration_file_resolution() {
+ let project = TempProject::new();
+ let entry = project.write("entry.tsx", "import { Typed } from './Typed';");
+ project.write("Typed.d.ts", "export declare function Typed(): unknown;");
+
+ assert!(is_rejected_dts(&project.root.join("Typed.d.ts")));
+ assert_eq!(resolve_import(&entry, "./Typed.d.ts"), None);
+ assert_eq!(resolve_import(&entry, "./Typed"), None);
+ }
+
+ #[test]
+ fn resolves_js_specifier_to_ts_index_with_extension_alias() {
+ let project = TempProject::new();
+ let entry = project.write(
+ "entry.tsx",
+ "import { Widget } from './components/index.js';",
+ );
+ let index = project.write(
+ "components/index.ts",
+ "export function Widget() { return null; }",
+ );
+
+ assert_eq!(resolve_import(&entry, "./components/index.js"), Some(index));
+ }
+
+ #[test]
+ fn resolves_directory_import_to_index_main_file() {
+ let project = TempProject::new();
+ let entry = project.write("entry.tsx", "import { Widget } from './components';");
+ let index = project.write(
+ "components/index.tsx",
+ "export function Widget() { return null; }",
+ );
+
+ assert_eq!(resolve_import(&entry, "./components"), Some(index));
+ }
+}
diff --git a/packages/core/src/utf16.rs b/packages/core/src/utf16.rs
new file mode 100644
index 0000000..32945dd
--- /dev/null
+++ b/packages/core/src/utf16.rs
@@ -0,0 +1,446 @@
+//! UTF-8 byte offset -> UTF-16 code-unit offset mapping.
+//!
+//! oxc spans are UTF-8 byte offsets; CONTRACT requires UTF-16 code units.
+//! Implemented in P3-W2 (Task 2).
+
+/// Line index over UTF-16 code-unit offsets.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Utf16LineIndex {
+ line_starts: Vec,
+}
+
+impl Utf16LineIndex {
+ #[must_use]
+ pub fn new(source: &str) -> Self {
+ let mut line_starts = vec![0];
+ let mut offset = 0_u32;
+
+ for ch in source.chars() {
+ offset += if ch.len_utf16() == 1 { 1 } else { 2 };
+ if ch == '\n' {
+ line_starts.push(offset);
+ }
+ }
+
+ Self { line_starts }
+ }
+
+ #[must_use]
+ pub fn position(&self, utf16_offset: u32) -> (u32, u32) {
+ let line_index = self
+ .line_starts
+ .partition_point(|start| *start <= utf16_offset);
+ let line = line_index.saturating_sub(1);
+ let line_start = self.line_starts[line];
+ (
+ u32::try_from(line).unwrap_or(u32::MAX),
+ utf16_offset.saturating_sub(line_start),
+ )
+ }
+}
+
+/// Maps UTF-8 byte offsets (oxc spans) to UTF-16 code-unit offsets (CONTRACT §2).
+pub struct Utf16Mapper {
+ // Precomputed mapping: Vec of (byte_offset, utf16_offset) at char boundaries
+ mapping: Vec<(u32, u32)>,
+ newline_utf16_offsets: Vec,
+ source_len: u32,
+}
+
+impl Utf16Mapper {
+ /// Build from the full source text.
+ #[must_use]
+ pub fn new(source: &str) -> Self {
+ let mut mapping = vec![(0, 0)];
+ let mut newline_utf16_offsets = Vec::new();
+ let mut byte_offset = 0u32;
+ let mut utf16_offset = 0u32;
+
+ for ch in source.chars() {
+ if ch == '\n' {
+ newline_utf16_offsets.push(utf16_offset);
+ }
+ byte_offset += u32::try_from(ch.len_utf8()).unwrap_or(u32::MAX);
+ utf16_offset += u32::try_from(ch.len_utf16()).unwrap_or(u32::MAX);
+ mapping.push((byte_offset, utf16_offset));
+ }
+
+ let source_len = u32::try_from(source.len()).unwrap_or(u32::MAX);
+
+ Self {
+ mapping,
+ newline_utf16_offsets,
+ source_len,
+ }
+ }
+
+ /// Convert a UTF-8 byte offset (must be a char boundary, or end of string)
+ /// to a UTF-16 code-unit offset. Offsets past end clamp to the UTF-16 length.
+ #[must_use]
+ pub fn to_utf16(&self, byte_offset: u32) -> u32 {
+ // Clamp to source length
+ let byte_offset = byte_offset.min(self.source_len);
+
+ // Binary search for the mapping entry
+ match self.mapping.binary_search_by_key(&byte_offset, |&(b, _)| b) {
+ Ok(idx) => self.mapping[idx].1,
+ Err(idx) => {
+ // idx is the insertion point; the previous entry is the closest
+ if idx > 0 { self.mapping[idx - 1].1 } else { 0 }
+ }
+ }
+ }
+
+ /// Convert a UTF-16 code-unit offset into a zero-based line and character
+ /// pair. Lines advance only on `\n`, matching JavaScript's
+ /// `sourceText.charCodeAt(i) === 10` scan.
+ #[must_use]
+ pub fn line_and_character_for_utf16(&self, utf16_offset: u32) -> (u32, u32) {
+ let source_utf16_len = self.mapping.last().map_or(0, |&(_, offset)| offset);
+ let utf16_offset = utf16_offset.min(source_utf16_len);
+ let newline_count = self
+ .newline_utf16_offsets
+ .partition_point(|&newline_offset| newline_offset < utf16_offset);
+ let line = u32::try_from(newline_count).unwrap_or(u32::MAX);
+ let character = self
+ .newline_utf16_offsets
+ .get(newline_count.saturating_sub(1))
+ .map_or(utf16_offset, |last_newline| {
+ utf16_offset.saturating_sub(*last_newline + 1)
+ });
+ (line, character)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rstest::rstest;
+
+ fn reference_utf16_offset(source: &str, byte_offset: u32) -> u32 {
+ let byte_offset = (byte_offset as usize).min(source.len());
+ source[..byte_offset]
+ .chars()
+ .map(|ch| u32::try_from(ch.len_utf16()).unwrap_or(u32::MAX))
+ .sum()
+ }
+
+ fn test_all_char_boundaries(source: &str, mapper: &Utf16Mapper, test_name: &str) {
+ let mut byte_offset = 0;
+ for ch in source.chars() {
+ let result = mapper.to_utf16(u32::try_from(byte_offset).unwrap_or(u32::MAX));
+ let expected =
+ reference_utf16_offset(source, u32::try_from(byte_offset).unwrap_or(u32::MAX));
+ assert_eq!(
+ result, expected,
+ "{test_name}: byte_offset={byte_offset} expected={expected} got={result}"
+ );
+ byte_offset += ch.len_utf8();
+ }
+ // Also test at end
+ let result = mapper.to_utf16(u32::try_from(source.len()).unwrap_or(u32::MAX));
+ let expected =
+ reference_utf16_offset(source, u32::try_from(source.len()).unwrap_or(u32::MAX));
+ assert_eq!(
+ result,
+ expected,
+ "{test_name}: end byte_offset={} expected={expected} got={result}",
+ source.len()
+ );
+ }
+
+ #[test]
+ fn test_pure_ascii() {
+ let source = "hello";
+ let mapper = Utf16Mapper::new(source);
+
+ // Test at every char boundary (ASCII is 1 byte per char)
+ for byte_offset in 0..=u32::try_from(source.len()).unwrap_or(u32::MAX) {
+ let result = mapper.to_utf16(byte_offset);
+ let expected = reference_utf16_offset(source, byte_offset);
+ assert_eq!(
+ result, expected,
+ "ASCII: byte_offset={byte_offset} expected={expected} got={result}"
+ );
+ }
+ }
+
+ #[test]
+ fn test_emoji_surrogate_pair() {
+ // 🦀 is U+1F980, which is 4 UTF-8 bytes and 2 UTF-16 code units
+ let source = "a🦀b";
+ let mapper = Utf16Mapper::new(source);
+
+ test_all_char_boundaries(source, &mapper, "emoji");
+ }
+
+ #[rstest]
+ #[case(0, 0, "start")]
+ #[case(1, 1, "after 'a'")]
+ #[case(5, 3, "after emoji (2 UTF-16 units)")]
+ #[case(6, 4, "end")]
+ fn test_emoji_surrogate_pair_explicit_offsets(
+ #[case] byte_offset: u32,
+ #[case] expected: u32,
+ #[case] message: &str,
+ ) {
+ // 🦀 is U+1F980, which is 4 UTF-8 bytes and 2 UTF-16 code units
+ let source = "a🦀b";
+ let mapper = Utf16Mapper::new(source);
+
+ // Byte offsets: a=0-1, 🦀=1-5, b=5-6
+ // UTF-16 offsets: a=0-1, 🦀=1-3, b=3-4
+ assert_eq!(mapper.to_utf16(byte_offset), expected, "{message}");
+ }
+
+ #[test]
+ fn test_cjk() {
+ // 中 is U+4E2D (3 UTF-8 bytes, 1 UTF-16 unit)
+ // 文 is U+6587 (3 UTF-8 bytes, 1 UTF-16 unit)
+ let source = "中文";
+ let mapper = Utf16Mapper::new(source);
+
+ test_all_char_boundaries(source, &mapper, "CJK");
+ }
+
+ #[rstest]
+ #[case(0, 0, "start")]
+ #[case(3, 1, "after 中")]
+ #[case(6, 2, "after 文")]
+ fn test_cjk_explicit_offsets(
+ #[case] byte_offset: u32,
+ #[case] expected: u32,
+ #[case] message: &str,
+ ) {
+ // 中 is U+4E2D (3 UTF-8 bytes, 1 UTF-16 unit)
+ // 文 is U+6587 (3 UTF-8 bytes, 1 UTF-16 unit)
+ let source = "中文";
+ let mapper = Utf16Mapper::new(source);
+
+ assert_eq!(mapper.to_utf16(byte_offset), expected, "{message}");
+ }
+
+ #[test]
+ fn test_combining_marks() {
+ // e + combining acute accent (U+0301)
+ let source = "e\u{0301}";
+ let mapper = Utf16Mapper::new(source);
+
+ test_all_char_boundaries(source, &mapper, "combining");
+ }
+
+ #[rstest]
+ #[case(0, 0, "start")]
+ #[case(1, 1, "after e")]
+ #[case(3, 2, "after combining mark")]
+ fn test_combining_marks_explicit_offsets(
+ #[case] byte_offset: u32,
+ #[case] expected: u32,
+ #[case] message: &str,
+ ) {
+ // e + combining acute accent (U+0301)
+ let source = "e\u{0301}";
+ let mapper = Utf16Mapper::new(source);
+
+ // e = 1 byte, 1 UTF-16 unit
+ // U+0301 = 2 bytes, 1 UTF-16 unit
+ assert_eq!(mapper.to_utf16(byte_offset), expected, "{message}");
+ }
+
+ #[test]
+ fn test_bom() {
+ // BOM (U+FEFF) = 3 UTF-8 bytes, 1 UTF-16 unit
+ let source = "\u{FEFF}use";
+ let mapper = Utf16Mapper::new(source);
+
+ test_all_char_boundaries(source, &mapper, "BOM");
+ }
+
+ #[rstest]
+ #[case(0, 0, "start")]
+ #[case(3, 1, "after BOM")]
+ #[case(4, 2, "after 'u'")]
+ #[case(7, 4, "end")]
+ fn test_bom_explicit_offsets(
+ #[case] byte_offset: u32,
+ #[case] expected: u32,
+ #[case] message: &str,
+ ) {
+ // BOM (U+FEFF) = 3 UTF-8 bytes, 1 UTF-16 unit
+ let source = "\u{FEFF}use";
+ let mapper = Utf16Mapper::new(source);
+
+ assert_eq!(mapper.to_utf16(byte_offset), expected, "{message}");
+ }
+
+ #[test]
+ fn test_crlf() {
+ let source = "a\r\nb";
+ let mapper = Utf16Mapper::new(source);
+
+ test_all_char_boundaries(source, &mapper, "CRLF");
+ }
+
+ #[rstest]
+ #[case(0, 0, "start")]
+ #[case(1, 1, "after 'a'")]
+ #[case(2, 2, "after \\r")]
+ #[case(3, 3, "after \\n")]
+ #[case(4, 4, "end")]
+ fn test_crlf_explicit_offsets(
+ #[case] byte_offset: u32,
+ #[case] expected: u32,
+ #[case] message: &str,
+ ) {
+ let source = "a\r\nb";
+ let mapper = Utf16Mapper::new(source);
+
+ // a = 1 byte, 1 UTF-16 unit
+ // \r = 1 byte, 1 UTF-16 unit
+ // \n = 1 byte, 1 UTF-16 unit
+ // b = 1 byte, 1 UTF-16 unit
+ assert_eq!(mapper.to_utf16(byte_offset), expected, "{message}");
+ }
+
+ #[test]
+ fn test_mixed_string() {
+ // Mix of ASCII, emoji, CJK, combining marks
+ let source = "a🦀中e\u{0301}";
+ let mapper = Utf16Mapper::new(source);
+
+ test_all_char_boundaries(source, &mapper, "mixed");
+ }
+
+ #[rstest]
+ #[case(0, 0, "start")]
+ #[case(1, 1, "after 'a'")]
+ #[case(5, 3, "after emoji")]
+ #[case(8, 4, "after CJK")]
+ #[case(9, 5, "after 'e'")]
+ #[case(11, 6, "end")]
+ fn test_mixed_string_explicit_offsets(
+ #[case] byte_offset: u32,
+ #[case] expected: u32,
+ #[case] message: &str,
+ ) {
+ // Mix of ASCII, emoji, CJK, combining marks
+ let source = "a🦀中e\u{0301}";
+ let mapper = Utf16Mapper::new(source);
+
+ // a = 1 byte, 1 UTF-16 unit
+ // 🦀 = 4 bytes, 2 UTF-16 units
+ // 中 = 3 bytes, 1 UTF-16 unit
+ // e = 1 byte, 1 UTF-16 unit
+ // U+0301 = 2 bytes, 1 UTF-16 unit
+ // Total: 11 bytes, 6 UTF-16 units
+ assert_eq!(mapper.to_utf16(byte_offset), expected, "{message}");
+ }
+
+ #[test]
+ fn test_offset_past_end_clamped() {
+ let source = "hello";
+ let mapper = Utf16Mapper::new(source);
+
+ // Offsets past end should clamp to source length
+ assert_eq!(mapper.to_utf16(100), 5, "clamped to end");
+ assert_eq!(mapper.to_utf16(1000), 5, "clamped to end (large)");
+ }
+
+ #[test]
+ fn to_utf16_returns_exact_mapping_entry_for_multibyte_boundary() {
+ let source = "a🦀b";
+ let mapper = Utf16Mapper::new(source);
+
+ assert_eq!(mapper.to_utf16(5), 3, "exact boundary after emoji");
+ }
+
+ #[test]
+ fn to_utf16_returns_zero_before_first_mapping_entry() {
+ let mapper = Utf16Mapper {
+ mapping: vec![(2, 1)],
+ newline_utf16_offsets: Vec::new(),
+ source_len: 2,
+ };
+
+ assert_eq!(mapper.to_utf16(1), 0, "before first mapping entry");
+ }
+
+ #[test]
+ fn test_empty_string() {
+ let source = "";
+ let mapper = Utf16Mapper::new(source);
+
+ assert_eq!(mapper.to_utf16(0), 0, "empty string");
+ assert_eq!(mapper.to_utf16(1), 0, "empty string clamped");
+ }
+
+ #[rstest]
+ #[case(0, (0, 0))]
+ #[case(3, (0, 3))]
+ #[case(4, (1, 0))]
+ #[case(7, (1, 3))]
+ fn ascii_offsets_map_to_zero_based_positions(
+ #[case] offset: u32,
+ #[case] expected: (u32, u32),
+ ) {
+ let index = Utf16LineIndex::new("abc\ndef");
+
+ assert_eq!(index.position(offset), expected);
+ }
+
+ #[rstest]
+ #[case(1, (0, 1))]
+ #[case(3, (0, 3))]
+ #[case(4, (0, 4))]
+ #[case(5, (1, 0))]
+ fn emoji_counts_as_two_utf16_units(#[case] offset: u32, #[case] expected: (u32, u32)) {
+ let index = Utf16LineIndex::new("a😀b\n c");
+
+ assert_eq!(index.position(offset), expected);
+ }
+
+ #[rstest]
+ #[case(2, (0, 2))]
+ #[case(3, (1, 0))]
+ #[case(4, (1, 1))]
+ fn cjk_counts_as_one_utf16_unit_per_scalar(#[case] offset: u32, #[case] expected: (u32, u32)) {
+ let index = Utf16LineIndex::new("한글\n語");
+
+ assert_eq!(index.position(offset), expected);
+ }
+
+ #[rstest]
+ #[case(2, (0, 2))]
+ #[case(3, (0, 3))]
+ #[case(4, (1, 0))]
+ fn crlf_treats_carriage_return_as_line_character(
+ #[case] offset: u32,
+ #[case] expected: (u32, u32),
+ ) {
+ let index = Utf16LineIndex::new("ab\r\ncd");
+
+ assert_eq!(index.position(offset), expected);
+ }
+
+ #[rstest]
+ #[case(0, (0, 0))]
+ #[case(1, (0, 1))]
+ #[case(2, (0, 2))]
+ #[case(3, (1, 0))]
+ fn bom_is_a_normal_utf16_unit_for_positions(#[case] offset: u32, #[case] expected: (u32, u32)) {
+ let index = Utf16LineIndex::new("\u{feff}x\ny");
+
+ assert_eq!(index.position(offset), expected);
+ }
+
+ #[rstest]
+ #[case(4, (1, 0))]
+ #[case(7, (1, 3))]
+ #[case(8, (2, 0))]
+ #[case(13, (2, 5))]
+ fn multiline_offsets_use_nearest_line_start(#[case] offset: u32, #[case] expected: (u32, u32)) {
+ let index = Utf16LineIndex::new("one\ntwo\nthree");
+
+ assert_eq!(index.position(offset), expected);
+ }
+}
diff --git a/packages/core/tests/analyzer_api.rs b/packages/core/tests/analyzer_api.rs
new file mode 100644
index 0000000..4709bea
--- /dev/null
+++ b/packages/core/tests/analyzer_api.rs
@@ -0,0 +1,222 @@
+use std::collections::HashMap;
+use std::io;
+use std::path::{Path, PathBuf};
+
+use oxc_resolver::{FileMetadata, ResolveError};
+use rcl_core::{
+ Kind, analyze_source_with_host_and_scope, analyze_source_with_host_and_scope_and_fs,
+ analyzer::{ScopeConfig, SourceHost},
+ find_component_declaration,
+ resolver::FileSystem,
+};
+
+struct EmptyHost;
+
+impl SourceHost for EmptyHost {
+ fn read_to_string(&self, _file_path: &Path) -> Option {
+ None
+ }
+}
+
+fn file_path() -> PathBuf {
+ PathBuf::from("/proj/entry.tsx")
+}
+
+#[test]
+fn find_component_declaration_returns_single_line_ascii_position() {
+ let source = "export function Button(){}";
+
+ let position = find_component_declaration(&file_path(), source, "Button", &EmptyHost);
+
+ assert_eq!(position, Some((0, 16)));
+}
+
+#[test]
+fn find_component_declaration_counts_utf16_units_before_declaration_line() {
+ let source = "// 🦀 cool\nexport function Button(){}";
+
+ let position = find_component_declaration(&file_path(), source, "Button", &EmptyHost);
+
+ assert_eq!(position, Some((1, 16)));
+}
+
+#[test]
+fn find_component_declaration_handles_crlf_lines_like_typescript_oracle() {
+ let source = "// header\r\nexport function Button(){}";
+
+ let position = find_component_declaration(&file_path(), source, "Button", &EmptyHost);
+
+ assert_eq!(position, Some((1, 16)));
+}
+
+#[test]
+fn find_component_declaration_returns_none_for_absent_component_name() {
+ let source = "export function Button(){}";
+
+ let position = find_component_declaration(&file_path(), source, "Missing", &EmptyHost);
+
+ assert_eq!(position, None);
+}
+
+#[test]
+fn analyze_source_with_host_and_scope_can_emit_only_element_usages() {
+ let source = "function Button(){ return ; }\nexport function App(){ return ; }";
+ let scope = ScopeConfig {
+ declaration: false,
+ element: true,
+ export: false,
+ import: false,
+ r#type: false,
+ };
+
+ let usages = analyze_source_with_host_and_scope(&file_path(), source, scope, &EmptyHost);
+
+ assert_eq!(
+ usages
+ .iter()
+ .map(|usage| usage.tag_name.as_str())
+ .collect::>(),
+ vec!["Button"]
+ );
+}
+
+#[test]
+fn analyze_source_with_host_and_scope_covers_exported_variable_components() {
+ let source = "const { Ignored } = props; const lower = () => null; let Missing; export const Button = () => ;";
+ let scope = ScopeConfig {
+ declaration: true,
+ element: false,
+ export: false,
+ import: false,
+ r#type: false,
+ };
+
+ let usages = analyze_source_with_host_and_scope(&file_path(), source, scope, &EmptyHost);
+
+ assert_eq!(
+ usages
+ .iter()
+ .map(|usage| usage.tag_name.as_str())
+ .collect::>(),
+ vec!["Button"]
+ );
+}
+
+#[derive(Clone, Default)]
+struct MemoryProject {
+ files: HashMap,
+}
+
+impl MemoryProject {
+ fn with_files(files: &[(&Path, &str)]) -> Self {
+ Self {
+ files: files
+ .iter()
+ .map(|(path, contents)| ((*path).to_path_buf(), (*contents).to_string()))
+ .collect(),
+ }
+ }
+
+ fn is_dir(&self, path: &Path) -> bool {
+ self.files
+ .keys()
+ .any(|file_path| file_path != path && file_path.starts_with(path))
+ }
+
+ fn read_file(&self, path: &Path) -> io::Result {
+ self.files
+ .get(path)
+ .cloned()
+ .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, path.display().to_string()))
+ }
+}
+
+impl SourceHost for MemoryProject {
+ fn read_to_string(&self, file_path: &Path) -> Option {
+ self.files.get(file_path).cloned()
+ }
+}
+
+impl FileSystem for MemoryProject {
+ fn new() -> Self {
+ Self::default()
+ }
+
+ fn read(&self, path: &Path) -> io::Result> {
+ self.read_file(path).map(String::into_bytes)
+ }
+
+ fn read_to_string(&self, path: &Path) -> io::Result {
+ self.read_file(path)
+ }
+
+ fn metadata(&self, path: &Path) -> io::Result {
+ if self.files.contains_key(path) {
+ Ok(FileMetadata::new(true, false, false))
+ } else if self.is_dir(path) {
+ Ok(FileMetadata::new(false, true, false))
+ } else {
+ Err(io::Error::new(
+ io::ErrorKind::NotFound,
+ path.display().to_string(),
+ ))
+ }
+ }
+
+ fn symlink_metadata(&self, path: &Path) -> io::Result {
+ self.metadata(path)
+ }
+
+ fn read_link(&self, path: &Path) -> Result {
+ Err(io::Error::new(io::ErrorKind::NotFound, path.display().to_string()).into())
+ }
+
+ fn canonicalize(&self, path: &Path) -> io::Result {
+ self.metadata(path)?;
+ Ok(path.to_path_buf())
+ }
+}
+
+fn fs_project_path(relative_path: &str) -> PathBuf {
+ if cfg!(windows) {
+ PathBuf::from("C:/proj").join(relative_path)
+ } else {
+ PathBuf::from("/proj").join(relative_path)
+ }
+}
+
+#[test]
+fn analyze_source_with_host_and_scope_and_fs_resolves_aliased_barrel_to_client_component() {
+ let entry = fs_project_path("src/app/entry.tsx");
+ let tsconfig = fs_project_path("tsconfig.json");
+ let barrel = fs_project_path("src/ui/index.ts");
+ let button = fs_project_path("src/ui/button.tsx");
+ let source = "import { Button } from '@ui';\nexport function App(){ return ; }";
+ let project = MemoryProject::with_files(&[
+ (&entry, source),
+ (
+ &tsconfig,
+ r#"{"compilerOptions":{"baseUrl":".","paths":{"@ui":["src/ui/index.ts"]}}}"#,
+ ),
+ (&barrel, "export { Button } from './button';"),
+ (
+ &button,
+ "'use client';\nexport function Button() { return null; }",
+ ),
+ ]);
+ let scope = ScopeConfig {
+ declaration: false,
+ element: true,
+ export: false,
+ import: false,
+ r#type: false,
+ };
+
+ let usages =
+ analyze_source_with_host_and_scope_and_fs(&entry, source, scope, &project, &project);
+
+ assert_eq!(usages.len(), 1);
+ assert_eq!(usages[0].kind, Kind::Client);
+ assert_eq!(usages[0].tag_name, "Button");
+ assert_eq!(PathBuf::from(&usages[0].source_file_path), button);
+}
diff --git a/packages/core/tests/conformance.rs b/packages/core/tests/conformance.rs
new file mode 100644
index 0000000..6527723
--- /dev/null
+++ b/packages/core/tests/conformance.rs
@@ -0,0 +1,152 @@
+//! Conformance harness: every fixture analyzed by `rcl_core::analyze_fixture`
+//! must produce canonical JSON byte-identical to the committed golden
+//! (generated by the TypeScript oracle). Mirrors the TS conformance test in
+//! `packages/conformance-harness/test/conformance.test.ts`.
+
+use std::fs;
+use std::panic::{self, AssertUnwindSafe};
+use std::path::{Path, PathBuf};
+
+use rcl_core::{Kind, Range, Usage, analyzer, canonical};
+
+fn repo_conformance_dir(sub: &str) -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("..")
+ .join("..")
+ .join("conformance")
+ .join(sub)
+}
+
+fn collect_goldens(dir: &Path, out: &mut Vec) {
+ let Ok(entries) = fs::read_dir(dir) else {
+ return;
+ };
+ let mut names: Vec = entries.filter_map(|e| e.ok().map(|e| e.path())).collect();
+ names.sort();
+ for path in names {
+ if path.is_dir() {
+ collect_goldens(&path, out);
+ } else if path.extension().is_some_and(|e| e == "json") {
+ out.push(path);
+ }
+ }
+}
+
+/// Build the expected canonical string by re-serializing the golden's `usages`.
+fn expected_from_golden(golden_path: &Path) -> String {
+ let text = fs::read_to_string(golden_path).expect("read golden");
+ let value: serde_json::Value = serde_json::from_str(&text).expect("parse golden json");
+ let usages_json = value
+ .get("usages")
+ .and_then(serde_json::Value::as_array)
+ .expect("golden.usages array");
+
+ let mut usages: Vec = usages_json
+ .iter()
+ .map(|u| Usage {
+ kind: match u.get("kind").and_then(serde_json::Value::as_str) {
+ Some("client") => Kind::Client,
+ _ => Kind::Server,
+ },
+ tag_name: u
+ .get("tagName")
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or_default()
+ .to_string(),
+ source_file_path: u
+ .get("sourceFilePath")
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or_default()
+ .to_string(),
+ ranges: u
+ .get("ranges")
+ .and_then(serde_json::Value::as_array)
+ .map(|rs| {
+ rs.iter()
+ .map(|r| Range {
+ start: u32::try_from(
+ r.get("start")
+ .and_then(serde_json::Value::as_u64)
+ .unwrap_or(0),
+ )
+ .unwrap_or(0),
+ end: u32::try_from(
+ r.get("end")
+ .and_then(serde_json::Value::as_u64)
+ .unwrap_or(0),
+ )
+ .unwrap_or(0),
+ })
+ .collect()
+ })
+ .unwrap_or_default(),
+ })
+ .collect();
+
+ canonical::serialize_canonical(&mut usages)
+}
+
+/// Map `conformance/goldens/.json` -> `conformance/fixtures/`.
+fn fixture_dir_for(golden: &Path, goldens_root: &Path, fixtures_root: &Path) -> PathBuf {
+ let rel = golden
+ .strip_prefix(goldens_root)
+ .expect("golden under goldens root");
+ let rel_no_ext = rel.with_extension("");
+ fixtures_root.join(rel_no_ext)
+}
+
+#[test]
+fn conformance_corpus_matches_oracle_goldens() {
+ let goldens_root = repo_conformance_dir("goldens");
+ let fixtures_root = repo_conformance_dir("fixtures");
+
+ let mut goldens = Vec::new();
+ collect_goldens(&goldens_root, &mut goldens);
+ assert!(
+ !goldens.is_empty(),
+ "no goldens found under {goldens_root:?}"
+ );
+
+ let mut passed = 0usize;
+ let mut failures: Vec = Vec::new();
+
+ for golden in &goldens {
+ let fixture_dir = fixture_dir_for(golden, &goldens_root, &fixtures_root);
+ let name = golden
+ .strip_prefix(&goldens_root)
+ .unwrap_or(golden)
+ .to_string_lossy()
+ .replace('\\', "/");
+ let expected = expected_from_golden(golden);
+
+ let actual =
+ panic::catch_unwind(AssertUnwindSafe(|| analyzer::analyze_fixture(&fixture_dir)));
+
+ match actual {
+ Ok(actual) if actual == expected => passed += 1,
+ Ok(actual) => {
+ let at = actual
+ .char_indices()
+ .zip(expected.char_indices())
+ .position(|((_, a), (_, b))| a != b)
+ .unwrap_or(actual.len().min(expected.len()));
+ failures.push(format!(
+ "{name}: MISMATCH at ~{at}\n expected: {}\n actual: {}",
+ &expected[at.saturating_sub(0)..expected.len().min(at + 80)],
+ &actual[at.saturating_sub(0)..actual.len().min(at + 80)],
+ ));
+ }
+ Err(_) => failures.push(format!("{name}: PANIC (unimplemented or bug)")),
+ }
+ }
+
+ let total = goldens.len();
+ eprintln!("conformance: {passed}/{total} passing");
+ assert!(
+ failures.is_empty(),
+ "{}/{} fixtures failed:\n{}",
+ failures.len(),
+ total,
+ failures.join("\n")
+ );
+}
diff --git a/packages/core/tests/resolver_generic.rs b/packages/core/tests/resolver_generic.rs
new file mode 100644
index 0000000..060fe75
--- /dev/null
+++ b/packages/core/tests/resolver_generic.rs
@@ -0,0 +1,140 @@
+use std::collections::HashMap;
+use std::io;
+use std::path::{Path, PathBuf};
+
+use oxc_resolver::{FileMetadata, ResolveError};
+use rcl_core::resolver::{FileSystem, find_nearest_config_with_fs, resolve_import_with_fs};
+
+#[derive(Clone, Default)]
+struct InMemoryFs {
+ files: HashMap,
+}
+
+impl InMemoryFs {
+ fn with_files(files: &[(&Path, &str)]) -> Self {
+ Self {
+ files: files
+ .iter()
+ .map(|(path, contents)| ((*path).to_path_buf(), (*contents).to_string()))
+ .collect(),
+ }
+ }
+
+ fn is_dir(&self, path: &Path) -> bool {
+ self.files
+ .keys()
+ .any(|file_path| file_path != path && file_path.starts_with(path))
+ }
+}
+
+impl FileSystem for InMemoryFs {
+ fn new() -> Self {
+ Self::default()
+ }
+
+ fn read(&self, path: &Path) -> io::Result> {
+ self.read_to_string(path).map(String::into_bytes)
+ }
+
+ fn read_to_string(&self, path: &Path) -> io::Result {
+ self.files
+ .get(path)
+ .cloned()
+ .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, path.display().to_string()))
+ }
+
+ fn metadata(&self, path: &Path) -> io::Result {
+ if self.files.contains_key(path) {
+ Ok(FileMetadata::new(true, false, false))
+ } else if self.is_dir(path) {
+ Ok(FileMetadata::new(false, true, false))
+ } else {
+ Err(io::Error::new(
+ io::ErrorKind::NotFound,
+ path.display().to_string(),
+ ))
+ }
+ }
+
+ fn symlink_metadata(&self, path: &Path) -> io::Result {
+ self.metadata(path)
+ }
+
+ fn read_link(&self, path: &Path) -> Result {
+ Err(io::Error::new(io::ErrorKind::NotFound, path.display().to_string()).into())
+ }
+
+ fn canonicalize(&self, path: &Path) -> io::Result {
+ self.metadata(path)?;
+ Ok(path.to_path_buf())
+ }
+}
+
+fn project_path(relative_path: &str) -> PathBuf {
+ if cfg!(windows) {
+ PathBuf::from("C:/proj").join(relative_path)
+ } else {
+ PathBuf::from("/proj").join(relative_path)
+ }
+}
+
+#[test]
+fn find_nearest_config_with_fs_uses_injected_filesystem() {
+ let entry = project_path("src/app/entry.tsx");
+ let tsconfig = project_path("tsconfig.json");
+ let fs = InMemoryFs::with_files(&[
+ (&entry, "import { Button } from './button';"),
+ (&tsconfig, "{}"),
+ ]);
+
+ assert_eq!(find_nearest_config_with_fs(&entry, &fs), Some(tsconfig));
+}
+
+#[test]
+fn find_nearest_config_with_fs_returns_none_when_missing() {
+ let entry = project_path("src/app/entry.tsx");
+ let fs = InMemoryFs::with_files(&[(&entry, "import { Button } from './button';")]);
+
+ assert_eq!(find_nearest_config_with_fs(&entry, &fs), None);
+}
+
+#[test]
+fn resolve_import_with_fs_resolves_relative_imports() {
+ let entry = project_path("entry.tsx");
+ let button = project_path("button.tsx");
+ let fs = InMemoryFs::with_files(&[
+ (&entry, "import { Button } from './button';"),
+ (&button, "export function Button() { return null; }"),
+ ]);
+
+ assert_eq!(resolve_import_with_fs(&entry, "./button", fs), Some(button));
+}
+
+#[test]
+fn resolve_import_with_fs_resolves_tsconfig_paths() {
+ let entry = project_path("src/app/entry.tsx");
+ let tsconfig = project_path("tsconfig.json");
+ let index = project_path("src/ui/index.ts");
+ let fs = InMemoryFs::with_files(&[
+ (&entry, "import { Button } from '@ui/index';"),
+ (
+ &tsconfig,
+ r#"{"compilerOptions":{"baseUrl":".","paths":{"@ui/*":["src/ui/*"]}}}"#,
+ ),
+ (&index, "export function Button() { return null; }"),
+ ]);
+
+ assert_eq!(resolve_import_with_fs(&entry, "@ui/index", fs), Some(index));
+}
+
+#[test]
+fn resolve_import_with_fs_rejects_declaration_files() {
+ let entry = project_path("entry.tsx");
+ let typed = project_path("typed.d.ts");
+ let fs = InMemoryFs::with_files(&[
+ (&entry, "import { Typed } from './typed';"),
+ (&typed, "export declare function Typed(): unknown;"),
+ ]);
+
+ assert_eq!(resolve_import_with_fs(&entry, "./typed", fs), None);
+}
diff --git a/packages/lsp/Cargo.toml b/packages/lsp/Cargo.toml
new file mode 100644
index 0000000..a5f2dc5
--- /dev/null
+++ b/packages/lsp/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "rcl-lsp"
+version = "0.3.0"
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[[bin]]
+name = "rcl-lsp"
+path = "src/main.rs"
+
+[lints]
+workspace = true
+
+[dependencies]
+tower-lsp = { version = "0.20", features = ["runtime-tokio"] }
+tokio = { version = "1", features = ["io-std", "io-util", "macros", "rt-multi-thread", "sync"] }
+dashmap = "6"
+rcl-core = { path = "../core" }
diff --git a/packages/lsp/src/main.rs b/packages/lsp/src/main.rs
new file mode 100644
index 0000000..1ad3199
--- /dev/null
+++ b/packages/lsp/src/main.rs
@@ -0,0 +1,656 @@
+use std::{
+ fs,
+ path::{Path, PathBuf},
+ sync::Arc,
+};
+
+use dashmap::DashMap;
+use rcl_core::{Kind, Range, analyzer::SourceHost, utf16::Utf16LineIndex};
+use tower_lsp::{
+ Client, LanguageServer, LspService, Server,
+ jsonrpc::Result,
+ lsp_types::{
+ DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
+ DidSaveTextDocumentParams, InitializeParams, InitializeResult, PositionEncodingKind,
+ SaveOptions, SemanticToken, SemanticTokenType, SemanticTokens, SemanticTokensFullOptions,
+ SemanticTokensLegend, SemanticTokensOptions, SemanticTokensParams, SemanticTokensResult,
+ SemanticTokensServerCapabilities, ServerCapabilities, TextDocumentSyncCapability,
+ TextDocumentSyncKind, TextDocumentSyncOptions, TextDocumentSyncSaveOptions, Url,
+ WorkDoneProgressOptions,
+ },
+};
+
+const CLIENT_TOKEN_TYPE: u32 = 0;
+const SERVER_TOKEN_TYPE: u32 = 1;
+
+fn latest_change_text(
+ changes: Vec,
+) -> Option {
+ changes.into_iter().last().map(|change| change.text)
+}
+
+struct Backend {
+ client: Client,
+ documents: Arc>,
+}
+
+#[cfg(not(tarpaulin_include))]
+#[tower_lsp::async_trait]
+impl LanguageServer for Backend {
+ async fn initialize(&self, _params: InitializeParams) -> Result {
+ Ok(InitializeResult {
+ capabilities: ServerCapabilities {
+ position_encoding: Some(PositionEncodingKind::UTF16),
+ text_document_sync: Some(TextDocumentSyncCapability::Options(
+ TextDocumentSyncOptions {
+ open_close: Some(true),
+ change: Some(TextDocumentSyncKind::FULL),
+ save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
+ include_text: Some(true),
+ })),
+ ..TextDocumentSyncOptions::default()
+ },
+ )),
+ semantic_tokens_provider: Some(
+ SemanticTokensServerCapabilities::SemanticTokensOptions(
+ SemanticTokensOptions {
+ work_done_progress_options: WorkDoneProgressOptions::default(),
+ legend: semantic_tokens_legend(),
+ range: None,
+ full: Some(SemanticTokensFullOptions::Bool(true)),
+ },
+ ),
+ ),
+ ..ServerCapabilities::default()
+ },
+ server_info: None,
+ })
+ }
+
+ async fn did_open(&self, params: DidOpenTextDocumentParams) {
+ self.documents
+ .insert(params.text_document.uri, params.text_document.text);
+ self.refresh_semantic_tokens().await;
+ }
+
+ async fn did_change(&self, params: DidChangeTextDocumentParams) {
+ if let Some(text) = latest_change_text(params.content_changes) {
+ self.documents.insert(params.text_document.uri, text);
+ }
+ self.refresh_semantic_tokens().await;
+ }
+
+ async fn did_save(&self, _params: DidSaveTextDocumentParams) {
+ self.refresh_semantic_tokens().await;
+ }
+
+ async fn did_close(&self, params: DidCloseTextDocumentParams) {
+ self.documents.remove(¶ms.text_document.uri);
+ self.refresh_semantic_tokens().await;
+ }
+
+ async fn semantic_tokens_full(
+ &self,
+ params: SemanticTokensParams,
+ ) -> Result