1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 17:06:04 +00:00

Add include directive for multi-file projects

Parser/preprocess module:
- New src/parser/preprocess.rs handles source-level include inlining
- 'include "path"' at line start splices the target file's content
- Paths resolved relative to the including file's directory
- Circular include detection via visited set (canonicalized paths)
- Non-cycle re-includes allowed (sibling branches can reuse files)

main.rs:
- Both compile() and check() run preprocess_source() before parsing
- Include resolution failures produce a clear error message

Tests: 4 new preprocess unit tests (249 total)
- passthrough, include line parsing, temp-file roundtrip

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
This commit is contained in:
Claude 2026-04-12 10:06:58 +00:00
parent 18e9bed258
commit 6efef6c4c5
No known key found for this signature in database
3 changed files with 140 additions and 2 deletions

View file

@ -81,10 +81,15 @@ fn dump_asm(instructions: &[nescript::asm::Instruction]) {
}
fn compile(input: &PathBuf, _debug: bool, asm_dump: bool) -> Result<Vec<u8>, ()> {
let source = std::fs::read_to_string(input).map_err(|e| {
let raw_source = std::fs::read_to_string(input).map_err(|e| {
eprintln!("error: failed to read {}: {e}", input.display());
})?;
// Preprocess: inline include directives
let source = nescript::parser::preprocess_source(&raw_source, Some(input)).map_err(|e| {
eprintln!("error: {e}");
})?;
let filename = input.to_string_lossy();
// Parse
@ -141,10 +146,14 @@ fn compile(input: &PathBuf, _debug: bool, asm_dump: bool) -> Result<Vec<u8>, ()>
}
fn check(input: &PathBuf) -> Result<(), ()> {
let source = std::fs::read_to_string(input).map_err(|e| {
let raw_source = std::fs::read_to_string(input).map_err(|e| {
eprintln!("error: failed to read {}: {e}", input.display());
})?;
let source = nescript::parser::preprocess_source(&raw_source, Some(input)).map_err(|e| {
eprintln!("error: {e}");
})?;
let filename = input.to_string_lossy();
let (program, parse_diags) = nescript::parser::parse(&source);

View file

@ -1,7 +1,10 @@
pub mod ast;
pub mod preprocess;
#[cfg(test)]
mod tests;
pub use preprocess::preprocess as preprocess_source;
use crate::errors::{Diagnostic, ErrorCode};
use crate::lexer::{Span, Token, TokenKind};
use ast::*;

126
src/parser/preprocess.rs Normal file
View file

@ -0,0 +1,126 @@
//! Source-level include preprocessor.
//!
//! Before lexing, scan the source for `include "path"` directives at the
//! start of lines and splice in the contents of those files. Tracks an
//! include stack to detect circular includes.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
/// Preprocess a source file, inlining all `include "path"` directives.
pub fn preprocess(source: &str, source_path: Option<&Path>) -> Result<String, String> {
let mut visited = HashSet::new();
let base_dir = source_path
.and_then(|p| p.parent())
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
if let Some(p) = source_path {
if let Ok(canon) = p.canonicalize() {
visited.insert(canon);
}
}
process(source, &base_dir, &mut visited)
}
fn process(
source: &str,
base_dir: &Path,
visited: &mut HashSet<PathBuf>,
) -> Result<String, String> {
let mut out = String::new();
for line in source.lines() {
if let Some(path_str) = parse_include_line(line) {
let include_path = resolve_include(base_dir, path_str);
let canon = include_path
.canonicalize()
.map_err(|e| format!("failed to open '{}': {e}", include_path.display()))?;
if !visited.insert(canon.clone()) {
return Err(format!(
"circular include: '{}' is already in the include stack",
include_path.display()
));
}
let included = std::fs::read_to_string(&canon)
.map_err(|e| format!("failed to read '{}': {e}", canon.display()))?;
// Use the included file's directory as base for its own includes
let nested_base = canon
.parent()
.map_or_else(|| base_dir.to_path_buf(), std::path::Path::to_path_buf);
let processed = process(&included, &nested_base, visited)?;
out.push_str(&processed);
out.push('\n');
// Remove from visited so siblings can re-include (only cycles
// through the current branch are forbidden).
visited.remove(&canon);
} else {
out.push_str(line);
out.push('\n');
}
}
Ok(out)
}
/// Parse a line like `include "path/to/file.ne"` and extract the path.
/// Returns None if the line is not an include directive.
fn parse_include_line(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
let rest = trimmed.strip_prefix("include")?;
let rest = rest.trim_start();
let rest = rest.strip_prefix('"')?;
let end = rest.find('"')?;
Some(&rest[..end])
}
fn resolve_include(base_dir: &Path, path_str: &str) -> PathBuf {
let p = Path::new(path_str);
if p.is_absolute() {
p.to_path_buf()
} else {
base_dir.join(p)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preprocess_no_includes_passthrough() {
let src = "game \"T\" { mapper: NROM }\non frame { wait_frame }\nstart Main\n";
let out = preprocess(src, None).unwrap();
assert!(out.contains("game \"T\""));
}
#[test]
fn parse_include_line_valid() {
assert_eq!(parse_include_line("include \"foo.ne\""), Some("foo.ne"));
assert_eq!(parse_include_line(" include \"a/b.ne\""), Some("a/b.ne"));
}
#[test]
fn parse_include_line_invalid() {
assert_eq!(parse_include_line("var x: u8 = 0"), None);
assert_eq!(parse_include_line("include foo.ne"), None); // no quotes
assert_eq!(parse_include_line("// include \"foo.ne\""), None);
}
#[test]
fn preprocess_with_temp_file() {
use std::io::Write;
let dir = std::env::temp_dir();
let inc_path = dir.join("nescript_test_include.ne");
let main_path = dir.join("nescript_test_main.ne");
std::fs::write(&inc_path, "const SPEED: u8 = 5\n").unwrap();
let main_src = format!("include \"{}\"\nvar x: u8 = 0\n", inc_path.display());
let mut f = std::fs::File::create(&main_path).unwrap();
f.write_all(main_src.as_bytes()).unwrap();
let out = preprocess(&main_src, Some(&main_path)).unwrap();
assert!(out.contains("const SPEED"));
assert!(out.contains("var x: u8"));
let _ = std::fs::remove_file(&inc_path);
let _ = std::fs::remove_file(&main_path);
}
}