Skip to content
Open
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
15 changes: 14 additions & 1 deletion pd-vm-wasm/src/analyzer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ fn lint_compile_result(
LintReport { diagnostics }
}
}
// Milestone 5+ load/merge errors carry the compilation-wide source
// map; unwrap it so the branch below renders from the owning source.
Err(SourcePathError::SourceWithMap { error, .. }) => lint_compile_result(
source,
flavor,
path,
options,
Err(SourcePathError::Source(error)),
),
Err(SourcePathError::Source(SourceError::Parse(err))) => {
let mut diagnostics =
lint_trailing_function_return_semicolon_diagnostics(source, flavor);
Expand All @@ -115,7 +124,11 @@ fn lint_compile_result(
source, flavor, path, options,
));
let mut source_map = SourceMap::new();
let source_id = source_map.add_source("<lint>", source.to_string());
// Milestone 5+ compile errors name their owning source; register
// the root text under that name so the rendered snippet resolves
// against the right file.
let source_id =
source_map.add_source(err.source_name().unwrap_or("<lint>"), source.to_string());
let line = err.line().unwrap_or(0);
let span = err.line().and_then(|value| {
let span = source_map.line_span(source_id, value)?;
Expand Down
3 changes: 1 addition & 2 deletions pd-vm-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1169,8 +1169,7 @@ mod runtime_tests {
diagnostic.message
);
assert!(
diagnostic.rendered.contains("<lint>:")
&& diagnostic.rendered.contains("let value = if true => {"),
diagnostic.rendered.contains("let value = if true => {"),
"expected rendered diagnostic snippet, got {:?}",
diagnostic.rendered
);
Expand Down
9 changes: 9 additions & 0 deletions plans/2026-08-09_semantic-module-system.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Semantic Module System Implementation Plan

**Status (2026-08-09):** Milestones 1-7 complete, committed in b3ef8a7.
The semantic module graph is the sole file-module path: `rewrite.rs` and
`line_map.rs` are deleted, the synthetic imported-function prelude is gone,
and module sources are parsed verbatim with implicit-extern fallback and
resolved by `SymbolId` in the source loader (see
`src/compiler/source_loader.rs` module docs). Verification: `compiler_tests`
(215 tests incl. `semantic_module_m6_tests`), workspace all-features tests,
fmt, clippy (no new warnings), and `git diff --check` are green.

**Goal:** Replace textual import rewriting and synthetic declarations with a semantic module graph and symbol resolution model.

**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.
Expand Down
169 changes: 152 additions & 17 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,16 +332,51 @@ fn run_vm_loop(

fn render_source_path_error(source_path: &Path, err: &SourcePathError) -> String {
match err {
SourcePathError::Source(vm::SourceError::Parse(parse)) => {
let source = std::fs::read_to_string(source_path).unwrap_or_default();
SourcePathError::SourceWithMap { .. } => {
vm::render_source_path_error(source_path, err, true)
}
SourcePathError::Source(error) => render_source_error_at_path(source_path, None, error),
SourcePathError::InvalidImportSyntax {
path,
line,
message,
} => {
let source = std::fs::read_to_string(path).unwrap_or_default();
let mut source_map = SourceMap::new();
let source_id = source_map.add_source(source_path.display().to_string(), source);
let source_id = source_map.add_source(path.display().to_string(), source);
let parse = vm::ParseError::at_line(*line, message.clone())
.with_line_span_from_source(&source_map, source_id);
render_source_error(&source_map, &parse, true)
}
_ => err.to_string(),
}
}

fn render_source_error_at_path(
source_path: &Path,
source_override: Option<&str>,
error: &vm::SourceError,
) -> String {
match error {
vm::SourceError::Parse(parse) => {
let render_path = parse
.message
.split_once(": ")
.map(|(path, _)| Path::new(path))
.filter(|path| path.exists())
.unwrap_or(source_path);
let source = source_override
.filter(|_| render_path == source_path)
.map(str::to_owned)
.unwrap_or_else(|| std::fs::read_to_string(render_path).unwrap_or_default());
let mut source_map = SourceMap::new();
let source_id = source_map.add_source(render_path.display().to_string(), source);
let parse = parse
.clone()
.with_line_span_from_source(&source_map, source_id);
render_source_error(&source_map, &parse, true)
}
SourcePathError::Source(vm::SourceError::Compile(compile)) => {
vm::SourceError::Compile(compile) => {
let render_path = compile
.source_name()
.map(Path::new)
Expand All @@ -352,19 +387,6 @@ fn render_source_path_error(source_path: &Path, err: &SourcePathError) -> String
source_map.add_source(render_path.display().to_string(), source);
vm::render_compile_error(&source_map, compile, true)
}
SourcePathError::InvalidImportSyntax {
path,
line,
message,
} => {
let source = std::fs::read_to_string(path).unwrap_or_default();
let mut source_map = SourceMap::new();
let source_id = source_map.add_source(path.display().to_string(), source);
let parse = vm::ParseError::at_line(*line, message.clone())
.with_line_span_from_source(&source_map, source_id);
render_source_error(&source_map, &parse, true)
}
_ => err.to_string(),
}
}

Expand Down Expand Up @@ -2372,4 +2394,117 @@ mod tests {
fn repl_input_incomplete_for_trailing_operator() {
assert!(!super::is_repl_input_complete("let a = 1 +"));
}

fn cli_diagnostic_root(prefix: &str) -> std::path::PathBuf {
let unique = format!(
"{prefix}_{}_{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be valid")
.as_nanos()
);
let root = std::env::temp_dir().join(unique);
std::fs::create_dir_all(&root).expect("cli diagnostic root should be created");
root.canonicalize().unwrap_or(root)
}

#[test]
fn cli_nested_module_parse_error_renders_nested_source_frame() {
let root = cli_diagnostic_root("pd-vm-cli-nested-parse-diag");
let main_path = root.join("main.rss");
let nested_path = root.join("nested.rss");
std::fs::write(&main_path, "use self::nested as nested;\nnested::run();\n")
.expect("main fixture should write");
std::fs::write(&nested_path, "pub fn run( {\n").expect("nested fixture should write");

let error = match vm::compile_source_file_with_options(
&main_path,
vm::CompileSourceFileOptions::default(),
) {
Ok(_) => panic!("nested parse error fixture should fail"),
Err(error) => error,
};
let rendered = super::render_source_path_error(&main_path, &error);

// The rendered frame must belong to the nested source: its path, its
// line text, and an underline, not the root file.
assert!(
rendered.contains(&nested_path.display().to_string()),
"rendered diagnostic should name the nested path: {rendered}"
);
assert!(
rendered.contains("pub fn run( {"),
"rendered diagnostic should show the nested source line: {rendered}"
);
assert!(
rendered.contains('^'),
"rendered diagnostic should underline the nested source: {rendered}"
);
assert!(
!rendered.contains("use self::nested as nested;"),
"rendered diagnostic should not show the root source frame: {rendered}"
);

let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn cli_nested_strict_type_error_renders_nested_source_frame() {
let root = cli_diagnostic_root("pd-vm-cli-nested-strict-diag");
let main_path = root.join("main.rss");
let nested_path = root.join("nested.rss");
std::fs::write(&main_path, "use self::nested as nested;\nnested::run();\n")
.expect("main fixture should write");
std::fs::write(&nested_path, "pub fn run() -> unknown { 1 }\n")
.expect("nested fixture should write");

let error = match vm::compile_source_file_with_options(
&main_path,
vm::CompileSourceFileOptions::default(),
) {
Ok(_) => panic!("strict nested fixture should fail"),
Err(error) => error,
};
let rendered = super::render_source_path_error(&main_path, &error);

assert!(
rendered.contains(&nested_path.display().to_string()),
"rendered diagnostic should name the nested path: {rendered}"
);
assert!(
rendered.contains("pub fn run() -> unknown { 1 }"),
"rendered diagnostic should show the nested source line: {rendered}"
);
assert!(
rendered.contains('^'),
"rendered diagnostic should underline the nested source: {rendered}"
);

let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn render_source_error_uses_source_override_for_virtual_paths() {
let virtual_path = std::path::Path::new("__pd_vm_inmemory__/main.rss");
let error = vm::SourceError::Parse(vm::ParseError::at_line(2, "boom"));
let rendered = super::render_source_error_at_path(
virtual_path,
Some("line one\nline two target\nline three"),
&error,
);

assert!(
rendered.contains("__pd_vm_inmemory__/main.rss"),
"rendered diagnostic should name the virtual path: {rendered}"
);
assert!(
rendered.contains("line two target"),
"rendered diagnostic should show the override source line: {rendered}"
);
assert!(
rendered.contains('^'),
"rendered diagnostic should underline the override source: {rendered}"
);
}
}
8 changes: 8 additions & 0 deletions src/compiler/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,14 @@ impl Compiler {
let slot = self.ensure_function_value_slot(*index, type_args)?;
self.emit_copy_ldloc(slot)?;
}
// Resolved module targets are lowered into plain flat-index calls
// by `linker::merge_units`; reaching codegen means the merge
// missed a site.
Expr::ModuleFunctionRef(..)
| Expr::ModuleCall(..)
| Expr::UnresolvedFunctionRef { .. } => {
return Err(CompileError::UnresolvedModuleCall);
}
Expr::Call(index, _, args) => {
self.compile_function_call(*index, args)?;
}
Expand Down
79 changes: 73 additions & 6 deletions src/compiler/diagnostics.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use super::source_map::{SourceMap, Span};
use super::{CompileError, ParseError};
use super::{CompileError, ParseError, SourceError, SourcePathError};

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

pub fn render_compile_error(source_map: &SourceMap, err: &CompileError, _styled: bool) -> String {
let message = err.diagnostic_message();
let source_id = err
.source_name()
.and_then(|name| source_map.source_id_by_name(name))
.unwrap_or(0);
let source_name = err.source_name();

// Resolve the owning source id: by name when the error names its source,
// or as the single-file fallback used by inline/REPL compiles (a map with
// exactly one file at id 0). A named error is never rendered against
// another file: when its source is missing from the map it renders as a
// plain path/line message instead of misattributing the span.
let source_id = match source_name {
Some(name) => source_map.source_id_by_name(name),
None if source_map.file(0).is_some() && source_map.file(1).is_none() => Some(0),
None => None,
};

if let Some(line) = err.line()
&& let Some(source_id) = source_id
&& let Some(span) = source_map.line_span(source_id, line)
&& let Some(rendered) = render_span_snippet(source_map, span, &message)
{
return format!("compile error: {}", rendered.trim_end());
}

if let Some(line) = err.line() {
if let Some(source_name) = err.source_name() {
if let Some(source_name) = source_name {
return format!("compile error: {source_name}:{line}: {message}");
}
return format!("compile error: line {line}: {message}");
Expand All @@ -41,6 +50,64 @@ pub fn render_compile_error(source_map: &SourceMap, err: &CompileError, _styled:
format!("compile error: {message}")
}

/// Render a source error (parse or compile) against the compilation-wide
/// source map carried by a [`SourcePathError`] when present, falling back to
/// a map-less render otherwise. Parse errors whose span references a source
/// id outside the map keep their path-prefixed message.
pub fn render_source_path_error(
source_path: &std::path::Path,
err: &SourcePathError,
_styled: bool,
) -> String {
match err {
SourcePathError::SourceWithMap { error, sources } => match error {
SourceError::Parse(parse) => render_source_error(sources, parse, _styled),
SourceError::Compile(compile) => render_compile_error(sources, compile, _styled),
},
SourcePathError::Source(error) => match error {
SourceError::Parse(parse) => {
let render_path = parse
.message
.split_once(": ")
.map(|(path, _)| std::path::Path::new(path))
.filter(|path| path.exists())
.unwrap_or(source_path);
let source = std::fs::read_to_string(render_path).unwrap_or_default();
let mut source_map = SourceMap::new();
let source_id = source_map.add_source(render_path.display().to_string(), source);
let parse = parse
.clone()
.with_line_span_from_source(&source_map, source_id);
render_source_error(&source_map, &parse, _styled)
}
SourceError::Compile(compile) => {
let render_path = compile
.source_name()
.map(std::path::Path::new)
.filter(|path| path.exists())
.unwrap_or(source_path);
let source = std::fs::read_to_string(render_path).unwrap_or_default();
let mut source_map = SourceMap::new();
source_map.add_source(render_path.display().to_string(), source);
render_compile_error(&source_map, compile, _styled)
}
},
SourcePathError::InvalidImportSyntax {
path,
line,
message,
} => {
let source = std::fs::read_to_string(path).unwrap_or_default();
let mut source_map = SourceMap::new();
let source_id = source_map.add_source(path.display().to_string(), source);
let parse = ParseError::at_line(*line, message.clone())
.with_line_span_from_source(&source_map, source_id);
render_source_error(&source_map, &parse, _styled)
}
_ => err.to_string(),
}
}

fn render_span_snippet(source_map: &SourceMap, span: Span, message: &str) -> Option<String> {
let file = source_map.file(span.source_id)?;
let (line, col) = file.line_col_for_offset(span.lo)?;
Expand Down
Loading
Loading