Skip to content

Commit e2e554e

Browse files
committed
refactor(compiler): build a semantic module graph
1 parent 3c25941 commit e2e554e

43 files changed

Lines changed: 6616 additions & 1657 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pd-vm-wasm/src/analyzer.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ fn lint_compile_result(
9898
LintReport { diagnostics }
9999
}
100100
}
101+
// Milestone 5+ load/merge errors carry the compilation-wide source
102+
// map; unwrap it so the branch below renders from the owning source.
103+
Err(SourcePathError::SourceWithMap { error, .. }) => lint_compile_result(
104+
source,
105+
flavor,
106+
path,
107+
options,
108+
Err(SourcePathError::Source(error)),
109+
),
101110
Err(SourcePathError::Source(SourceError::Parse(err))) => {
102111
let mut diagnostics =
103112
lint_trailing_function_return_semicolon_diagnostics(source, flavor);
@@ -115,7 +124,11 @@ fn lint_compile_result(
115124
source, flavor, path, options,
116125
));
117126
let mut source_map = SourceMap::new();
118-
let source_id = source_map.add_source("<lint>", source.to_string());
127+
// Milestone 5+ compile errors name their owning source; register
128+
// the root text under that name so the rendered snippet resolves
129+
// against the right file.
130+
let source_id =
131+
source_map.add_source(err.source_name().unwrap_or("<lint>"), source.to_string());
119132
let line = err.line().unwrap_or(0);
120133
let span = err.line().and_then(|value| {
121134
let span = source_map.line_span(source_id, value)?;

pd-vm-wasm/src/lib.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,8 +1169,7 @@ mod runtime_tests {
11691169
diagnostic.message
11701170
);
11711171
assert!(
1172-
diagnostic.rendered.contains("<lint>:")
1173-
&& diagnostic.rendered.contains("let value = if true => {"),
1172+
diagnostic.rendered.contains("let value = if true => {"),
11741173
"expected rendered diagnostic snippet, got {:?}",
11751174
diagnostic.rendered
11761175
);

plans/2026-08-09_semantic-module-system.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Semantic Module System Implementation Plan
22

3+
**Status (2026-08-09):** Milestones 1-7 complete, committed in b3ef8a7.
4+
The semantic module graph is the sole file-module path: `rewrite.rs` and
5+
`line_map.rs` are deleted, the synthetic imported-function prelude is gone,
6+
and module sources are parsed verbatim with implicit-extern fallback and
7+
resolved by `SymbolId` in the source loader (see
8+
`src/compiler/source_loader.rs` module docs). Verification: `compiler_tests`
9+
(215 tests incl. `semantic_module_m6_tests`), workspace all-features tests,
10+
fmt, clippy (no new warnings), and `git diff --check` are green.
11+
312
**Goal:** Replace textual import rewriting and synthetic declarations with a semantic module graph and symbol resolution model.
413

514
**Architecture:** Parse imports as syntax, assign every source a `ModuleId` and `SourceId`, resolve declarations to `SymbolId`, and link by resolved identity. Module namespaces, visibility, private helpers, and diagnostics become first-class compiler data instead of rewritten text and parallel metadata arrays.

src/cli.rs

Lines changed: 152 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -332,16 +332,51 @@ fn run_vm_loop(
332332

333333
fn render_source_path_error(source_path: &Path, err: &SourcePathError) -> String {
334334
match err {
335-
SourcePathError::Source(vm::SourceError::Parse(parse)) => {
336-
let source = std::fs::read_to_string(source_path).unwrap_or_default();
335+
SourcePathError::SourceWithMap { .. } => {
336+
vm::render_source_path_error(source_path, err, true)
337+
}
338+
SourcePathError::Source(error) => render_source_error_at_path(source_path, None, error),
339+
SourcePathError::InvalidImportSyntax {
340+
path,
341+
line,
342+
message,
343+
} => {
344+
let source = std::fs::read_to_string(path).unwrap_or_default();
337345
let mut source_map = SourceMap::new();
338-
let source_id = source_map.add_source(source_path.display().to_string(), source);
346+
let source_id = source_map.add_source(path.display().to_string(), source);
347+
let parse = vm::ParseError::at_line(*line, message.clone())
348+
.with_line_span_from_source(&source_map, source_id);
349+
render_source_error(&source_map, &parse, true)
350+
}
351+
_ => err.to_string(),
352+
}
353+
}
354+
355+
fn render_source_error_at_path(
356+
source_path: &Path,
357+
source_override: Option<&str>,
358+
error: &vm::SourceError,
359+
) -> String {
360+
match error {
361+
vm::SourceError::Parse(parse) => {
362+
let render_path = parse
363+
.message
364+
.split_once(": ")
365+
.map(|(path, _)| Path::new(path))
366+
.filter(|path| path.exists())
367+
.unwrap_or(source_path);
368+
let source = source_override
369+
.filter(|_| render_path == source_path)
370+
.map(str::to_owned)
371+
.unwrap_or_else(|| std::fs::read_to_string(render_path).unwrap_or_default());
372+
let mut source_map = SourceMap::new();
373+
let source_id = source_map.add_source(render_path.display().to_string(), source);
339374
let parse = parse
340375
.clone()
341376
.with_line_span_from_source(&source_map, source_id);
342377
render_source_error(&source_map, &parse, true)
343378
}
344-
SourcePathError::Source(vm::SourceError::Compile(compile)) => {
379+
vm::SourceError::Compile(compile) => {
345380
let render_path = compile
346381
.source_name()
347382
.map(Path::new)
@@ -352,19 +387,6 @@ fn render_source_path_error(source_path: &Path, err: &SourcePathError) -> String
352387
source_map.add_source(render_path.display().to_string(), source);
353388
vm::render_compile_error(&source_map, compile, true)
354389
}
355-
SourcePathError::InvalidImportSyntax {
356-
path,
357-
line,
358-
message,
359-
} => {
360-
let source = std::fs::read_to_string(path).unwrap_or_default();
361-
let mut source_map = SourceMap::new();
362-
let source_id = source_map.add_source(path.display().to_string(), source);
363-
let parse = vm::ParseError::at_line(*line, message.clone())
364-
.with_line_span_from_source(&source_map, source_id);
365-
render_source_error(&source_map, &parse, true)
366-
}
367-
_ => err.to_string(),
368390
}
369391
}
370392

@@ -2372,4 +2394,117 @@ mod tests {
23722394
fn repl_input_incomplete_for_trailing_operator() {
23732395
assert!(!super::is_repl_input_complete("let a = 1 +"));
23742396
}
2397+
2398+
fn cli_diagnostic_root(prefix: &str) -> std::path::PathBuf {
2399+
let unique = format!(
2400+
"{prefix}_{}_{}",
2401+
std::process::id(),
2402+
SystemTime::now()
2403+
.duration_since(UNIX_EPOCH)
2404+
.expect("clock should be valid")
2405+
.as_nanos()
2406+
);
2407+
let root = std::env::temp_dir().join(unique);
2408+
std::fs::create_dir_all(&root).expect("cli diagnostic root should be created");
2409+
root.canonicalize().unwrap_or(root)
2410+
}
2411+
2412+
#[test]
2413+
fn cli_nested_module_parse_error_renders_nested_source_frame() {
2414+
let root = cli_diagnostic_root("pd-vm-cli-nested-parse-diag");
2415+
let main_path = root.join("main.rss");
2416+
let nested_path = root.join("nested.rss");
2417+
std::fs::write(&main_path, "use self::nested as nested;\nnested::run();\n")
2418+
.expect("main fixture should write");
2419+
std::fs::write(&nested_path, "pub fn run( {\n").expect("nested fixture should write");
2420+
2421+
let error = match vm::compile_source_file_with_options(
2422+
&main_path,
2423+
vm::CompileSourceFileOptions::default(),
2424+
) {
2425+
Ok(_) => panic!("nested parse error fixture should fail"),
2426+
Err(error) => error,
2427+
};
2428+
let rendered = super::render_source_path_error(&main_path, &error);
2429+
2430+
// The rendered frame must belong to the nested source: its path, its
2431+
// line text, and an underline, not the root file.
2432+
assert!(
2433+
rendered.contains(&nested_path.display().to_string()),
2434+
"rendered diagnostic should name the nested path: {rendered}"
2435+
);
2436+
assert!(
2437+
rendered.contains("pub fn run( {"),
2438+
"rendered diagnostic should show the nested source line: {rendered}"
2439+
);
2440+
assert!(
2441+
rendered.contains('^'),
2442+
"rendered diagnostic should underline the nested source: {rendered}"
2443+
);
2444+
assert!(
2445+
!rendered.contains("use self::nested as nested;"),
2446+
"rendered diagnostic should not show the root source frame: {rendered}"
2447+
);
2448+
2449+
let _ = std::fs::remove_dir_all(&root);
2450+
}
2451+
2452+
#[test]
2453+
fn cli_nested_strict_type_error_renders_nested_source_frame() {
2454+
let root = cli_diagnostic_root("pd-vm-cli-nested-strict-diag");
2455+
let main_path = root.join("main.rss");
2456+
let nested_path = root.join("nested.rss");
2457+
std::fs::write(&main_path, "use self::nested as nested;\nnested::run();\n")
2458+
.expect("main fixture should write");
2459+
std::fs::write(&nested_path, "pub fn run() -> unknown { 1 }\n")
2460+
.expect("nested fixture should write");
2461+
2462+
let error = match vm::compile_source_file_with_options(
2463+
&main_path,
2464+
vm::CompileSourceFileOptions::default(),
2465+
) {
2466+
Ok(_) => panic!("strict nested fixture should fail"),
2467+
Err(error) => error,
2468+
};
2469+
let rendered = super::render_source_path_error(&main_path, &error);
2470+
2471+
assert!(
2472+
rendered.contains(&nested_path.display().to_string()),
2473+
"rendered diagnostic should name the nested path: {rendered}"
2474+
);
2475+
assert!(
2476+
rendered.contains("pub fn run() -> unknown { 1 }"),
2477+
"rendered diagnostic should show the nested source line: {rendered}"
2478+
);
2479+
assert!(
2480+
rendered.contains('^'),
2481+
"rendered diagnostic should underline the nested source: {rendered}"
2482+
);
2483+
2484+
let _ = std::fs::remove_dir_all(&root);
2485+
}
2486+
2487+
#[test]
2488+
fn render_source_error_uses_source_override_for_virtual_paths() {
2489+
let virtual_path = std::path::Path::new("__pd_vm_inmemory__/main.rss");
2490+
let error = vm::SourceError::Parse(vm::ParseError::at_line(2, "boom"));
2491+
let rendered = super::render_source_error_at_path(
2492+
virtual_path,
2493+
Some("line one\nline two target\nline three"),
2494+
&error,
2495+
);
2496+
2497+
assert!(
2498+
rendered.contains("__pd_vm_inmemory__/main.rss"),
2499+
"rendered diagnostic should name the virtual path: {rendered}"
2500+
);
2501+
assert!(
2502+
rendered.contains("line two target"),
2503+
"rendered diagnostic should show the override source line: {rendered}"
2504+
);
2505+
assert!(
2506+
rendered.contains('^'),
2507+
"rendered diagnostic should underline the override source: {rendered}"
2508+
);
2509+
}
23752510
}

src/compiler/codegen.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,14 @@ impl Compiler {
668668
let slot = self.ensure_function_value_slot(*index, type_args)?;
669669
self.emit_copy_ldloc(slot)?;
670670
}
671+
// Resolved module targets are lowered into plain flat-index calls
672+
// by `linker::merge_units`; reaching codegen means the merge
673+
// missed a site.
674+
Expr::ModuleFunctionRef(..)
675+
| Expr::ModuleCall(..)
676+
| Expr::UnresolvedFunctionRef { .. } => {
677+
return Err(CompileError::UnresolvedModuleCall);
678+
}
671679
Expr::Call(index, _, args) => {
672680
self.compile_function_call(*index, args)?;
673681
}

src/compiler/diagnostics.rs

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use super::source_map::{SourceMap, Span};
2-
use super::{CompileError, ParseError};
2+
use super::{CompileError, ParseError, SourceError, SourcePathError};
33

44
pub fn render_source_error(source_map: &SourceMap, err: &ParseError, _styled: bool) -> String {
55
let code_prefix = err
@@ -19,20 +19,29 @@ pub fn render_source_error(source_map: &SourceMap, err: &ParseError, _styled: bo
1919

2020
pub fn render_compile_error(source_map: &SourceMap, err: &CompileError, _styled: bool) -> String {
2121
let message = err.diagnostic_message();
22-
let source_id = err
23-
.source_name()
24-
.and_then(|name| source_map.source_id_by_name(name))
25-
.unwrap_or(0);
22+
let source_name = err.source_name();
23+
24+
// Resolve the owning source id: by name when the error names its source,
25+
// or as the single-file fallback used by inline/REPL compiles (a map with
26+
// exactly one file at id 0). A named error is never rendered against
27+
// another file: when its source is missing from the map it renders as a
28+
// plain path/line message instead of misattributing the span.
29+
let source_id = match source_name {
30+
Some(name) => source_map.source_id_by_name(name),
31+
None if source_map.file(0).is_some() && source_map.file(1).is_none() => Some(0),
32+
None => None,
33+
};
2634

2735
if let Some(line) = err.line()
36+
&& let Some(source_id) = source_id
2837
&& let Some(span) = source_map.line_span(source_id, line)
2938
&& let Some(rendered) = render_span_snippet(source_map, span, &message)
3039
{
3140
return format!("compile error: {}", rendered.trim_end());
3241
}
3342

3443
if let Some(line) = err.line() {
35-
if let Some(source_name) = err.source_name() {
44+
if let Some(source_name) = source_name {
3645
return format!("compile error: {source_name}:{line}: {message}");
3746
}
3847
return format!("compile error: line {line}: {message}");
@@ -41,6 +50,64 @@ pub fn render_compile_error(source_map: &SourceMap, err: &CompileError, _styled:
4150
format!("compile error: {message}")
4251
}
4352

53+
/// Render a source error (parse or compile) against the compilation-wide
54+
/// source map carried by a [`SourcePathError`] when present, falling back to
55+
/// a map-less render otherwise. Parse errors whose span references a source
56+
/// id outside the map keep their path-prefixed message.
57+
pub fn render_source_path_error(
58+
source_path: &std::path::Path,
59+
err: &SourcePathError,
60+
_styled: bool,
61+
) -> String {
62+
match err {
63+
SourcePathError::SourceWithMap { error, sources } => match error {
64+
SourceError::Parse(parse) => render_source_error(sources, parse, _styled),
65+
SourceError::Compile(compile) => render_compile_error(sources, compile, _styled),
66+
},
67+
SourcePathError::Source(error) => match error {
68+
SourceError::Parse(parse) => {
69+
let render_path = parse
70+
.message
71+
.split_once(": ")
72+
.map(|(path, _)| std::path::Path::new(path))
73+
.filter(|path| path.exists())
74+
.unwrap_or(source_path);
75+
let source = std::fs::read_to_string(render_path).unwrap_or_default();
76+
let mut source_map = SourceMap::new();
77+
let source_id = source_map.add_source(render_path.display().to_string(), source);
78+
let parse = parse
79+
.clone()
80+
.with_line_span_from_source(&source_map, source_id);
81+
render_source_error(&source_map, &parse, _styled)
82+
}
83+
SourceError::Compile(compile) => {
84+
let render_path = compile
85+
.source_name()
86+
.map(std::path::Path::new)
87+
.filter(|path| path.exists())
88+
.unwrap_or(source_path);
89+
let source = std::fs::read_to_string(render_path).unwrap_or_default();
90+
let mut source_map = SourceMap::new();
91+
source_map.add_source(render_path.display().to_string(), source);
92+
render_compile_error(&source_map, compile, _styled)
93+
}
94+
},
95+
SourcePathError::InvalidImportSyntax {
96+
path,
97+
line,
98+
message,
99+
} => {
100+
let source = std::fs::read_to_string(path).unwrap_or_default();
101+
let mut source_map = SourceMap::new();
102+
let source_id = source_map.add_source(path.display().to_string(), source);
103+
let parse = ParseError::at_line(*line, message.clone())
104+
.with_line_span_from_source(&source_map, source_id);
105+
render_source_error(&source_map, &parse, _styled)
106+
}
107+
_ => err.to_string(),
108+
}
109+
}
110+
44111
fn render_span_snippet(source_map: &SourceMap, span: Span, message: &str) -> Option<String> {
45112
let file = source_map.file(span.source_id)?;
46113
let (line, col) = file.line_col_for_offset(span.lo)?;

0 commit comments

Comments
 (0)