Skip to content
Draft
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
23 changes: 18 additions & 5 deletions compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,25 @@ cargo test --manifest-path compiler/Cargo.toml -p orangec --test s3a_conformance
`orangec` accepts up to 256 source inputs in argument order. Regular files are
processed incrementally; `-` is the only stream input and reads standard input
at most once. `eval` accepts exactly one source and emits no partial result after
an error. Successful `check` commands are silent. Diagnostics go to standard
error and use exit status 1; command-line usage errors use status 2. A source
a language or evaluation error. Successful `check` commands are silent.
Diagnostics go to standard error and use exit status 1; command-line usage
errors use status 2. A source
with lexical errors is not parsed, and a source with syntax errors is not
analyzed. File and standard-input reads stop at a deterministic 16 MiB
per-source limit. Larger inputs fail with `ORC1003` before lexing and are never
buffered without a bound.
analyzed. Output I/O failures use status 1; a broken pipe remains quiet but is
not reported as successful evaluation. Displayed source names and echoed
command or option text escape control and non-ASCII data; invalid encoded path
bytes use `\xNN` escapes instead of lossy replacement. File and standard-input
reads stop at a deterministic 16 MiB per-source limit. Larger inputs fail with
`ORC1003` before lexing and are never buffered without a bound.

Accepted S3a has no separate evaluation-output byte limit. Each successful
output line repeats the module name, so a source with a long module name and
many typed specifications can request output much larger than its input. The
CLI streams values only after complete analysis and evaluation, which bounds
compiler-owned output buffering but does not bound the requested bytes or time.
Apply caller-side output and time limits before using `orangec eval` on
untrusted sources. Adding a fail-closed output ceiling requires an explicit
edition-aware semantic decision because it would change accepted S3a behavior.

## Frozen lexical boundary

Expand Down
85 changes: 60 additions & 25 deletions compiler/crates/orange-compiler/src/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,13 +447,13 @@ fn render_excerpt(
if left_truncated {
excerpt.push_str("... ");
}
excerpt.push_str(&sanitize_inline(&line[window_start..window_end]));
excerpt.push_str(&sanitize_source_inline(&line[window_start..window_end]));
if right_truncated {
excerpt.push_str(" ...");
}

let caret_offset =
usize::from(left_truncated) * 4 + sanitized_width(&line[window_start..relative_start]);
let caret_offset = usize::from(left_truncated) * 4
+ sanitized_source_width(&line[window_start..relative_start]);
let relative_end = usize::try_from(span.end().bytes().saturating_sub(line_start.bytes()))
.unwrap_or(line.len())
.min(window_end)
Expand All @@ -463,17 +463,15 @@ fn render_excerpt(
{
1
} else {
sanitized_width(&line[relative_start..relative_end]).max(1)
sanitized_source_width(&line[relative_start..relative_end]).max(1)
};
(excerpt, caret_offset, caret_width)
}

fn sanitize_inline(text: &str) -> String {
let mut sanitized = String::new();
for character in text.chars() {
if character == '\t' {
sanitized.push_str(" ");
} else if character.is_control() || is_bidi_control(character) {
if !character.is_ascii_graphic() && character != ' ' {
sanitized.extend(character.escape_default());
} else {
sanitized.push(character);
Expand All @@ -482,12 +480,22 @@ fn sanitize_inline(text: &str) -> String {
sanitized
}

fn sanitized_width(text: &str) -> usize {
fn sanitize_source_inline(text: &str) -> String {
let mut sanitized = String::new();
for character in text.chars() {
if character == '\\' || (!character.is_ascii_graphic() && character != ' ') {
sanitized.extend(character.escape_default());
} else {
sanitized.push(character);
}
}
sanitized
}

fn sanitized_source_width(text: &str) -> usize {
text.chars()
.map(|character| {
if character == '\t' {
4
} else if character.is_control() || is_bidi_control(character) {
if character == '\\' || (!character.is_ascii_graphic() && character != ' ') {
character.escape_default().count()
} else {
1
Expand All @@ -496,13 +504,6 @@ fn sanitized_width(text: &str) -> usize {
.sum()
}

const fn is_bidi_control(character: char) -> bool {
matches!(
character,
'\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'
)
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -524,17 +525,51 @@ mod tests {

assert_eq!(
render_diagnostics(&sources, &[diagnostic]),
concat!(
"error[ORC0001]: unexpected character '@'\n",
" --> sample.or:1:7\n",
" |\n",
"1 | let é@\n",
" | ^ character is not part of Orange 2026\n",
" = note: identifiers are ASCII in this pre-alpha edition\n",
format!(
concat!(
"error[ORC0001]: unexpected character '@'\n",
" --> sample.or:1:7\n",
" |\n",
"1 | \\tlet \\u{{e9}}@\n",
" | {caret}^ character is not part of Orange 2026\n",
" = note: identifiers are ASCII in this pre-alpha edition\n",
),
caret = " ".repeat(12),
)
);
}

#[test]
fn escaping_distinguishes_tabs_spaces_backslashes_and_unicode() {
let source = "\t\\ é";
let sanitized = sanitize_source_inline(source);

assert_eq!(sanitized, r"\t\\ \u{e9}");
assert_eq!(sanitized_source_width(source), sanitized.chars().count());
}

#[test]
fn escapes_non_ascii_source_text_with_matching_caret_width() {
let mut sources = SourceMap::new();
let text = "a\u{200b}🟠@\n";
let id = sources.add("sample.or", text).unwrap();
let source = sources.get(id).unwrap();
let at = u32::try_from(text.find('@').unwrap()).unwrap();
let span = source
.span(TextOffset::new(at), TextOffset::new(at + 1))
.unwrap();
let diagnostic = Diagnostic::error(
DiagnosticCode::UnexpectedCharacter,
"unexpected character '@'",
span,
);

let rendered = render_diagnostics(&sources, &[diagnostic]);
assert!(rendered.is_ascii());
assert!(rendered.contains("1 | a\\u{200b}\\u{1f7e0}@\n"));
assert!(rendered.contains(&format!(" | {}^\n", " ".repeat(18))));
}

#[test]
fn sorts_diagnostics_by_source_position_then_code() {
let mut sources = SourceMap::new();
Expand Down
20 changes: 17 additions & 3 deletions compiler/crates/orange-compiler/src/eval.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Deterministic reference evaluation for typed Orange Core.

use std::fmt;
use std::sync::Arc;

use crate::core::{CoreFunctionId, CoreModule, CoreType, CoreValue};
use crate::diagnostic::{Diagnostic, DiagnosticCode};
Expand All @@ -14,7 +15,7 @@ pub struct EvaluatedFunction {
/// Identity copied from the source-ordered Core function.
pub id: CoreFunctionId,
/// Exact ASCII module name.
pub module: String,
pub module: Arc<str>,
/// Exact ASCII function name.
pub name: String,
/// Statically checked result type.
Expand Down Expand Up @@ -57,7 +58,8 @@ pub fn evaluate(core: &CoreModule) -> EvaluationResult {
}

fn evaluate_with_limit(core: &CoreModule, step_limit: usize) -> EvaluationResult {
let mut values = Vec::with_capacity(core.functions.len());
let mut values = Vec::with_capacity(core.functions.len().min(step_limit));
let module: Arc<str> = Arc::from(core.name.as_str());
for (steps, function) in core.functions.iter().enumerate() {
if steps >= step_limit {
return EvaluationResult {
Expand All @@ -78,7 +80,7 @@ fn evaluate_with_limit(core: &CoreModule, step_limit: usize) -> EvaluationResult
}
values.push(EvaluatedFunction {
id: function.id,
module: core.name.clone(),
module: Arc::clone(&module),
name: function.name.clone(),
result_type: function.result_type,
value: function.value.clone(),
Expand Down Expand Up @@ -169,4 +171,16 @@ mod tests {
let core = core("edition 2026; module values { spec answer() -> Int { 42 } }\n");
assert_eq!(evaluate(&core), evaluate(&core));
}

#[test]
fn module_identity_is_shared_across_evaluated_values() {
let core = core(concat!(
"edition 2026; module a_very_long_shared_module_name {\n",
" spec first() -> Int { 1 }\n",
" spec second() -> Int { 2 }\n",
"}\n",
));
let values = evaluate(&core).values.unwrap();
assert!(Arc::ptr_eq(&values[0].module, &values[1].module));
}
}
Loading