From ab7d50149885d2d96d69e8649802c120630e59c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 20:45:10 +0000 Subject: [PATCH 1/2] fix(license): restore MPL-2.0 on secret-scanner.yml + scorecard.yml Both workflow files carried SPDX PMPL-1.0, applied by a prior bulk license sweep (see CHANGELOG: "global AGPL-3.0-or-later -> PMPL-1.0-or-later replacement"). Per estate policy PMPL is reserved for palimpsest-license, palimpsest-plasma, and consent-aware-http only; empty-linter is a sole-owner repo and defaults to MPL-2.0 (matching every other workflow here). The identifier was also malformed (PMPL-1.0 vs the repo's PMPL-1.0-or-later). Owner-approved, per-file correction; no other files touched. https://claude.ai/code/session_01EqEysvTPzPwhS9ZGY7EYdr --- .github/workflows/scorecard.yml | 2 +- .github/workflows/secret-scanner.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 346971a..a28b67c 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -1,5 +1,5 @@ # // Copyright (c) Jonathan D.A. Jewell -# SPDX-License-Identifier: PMPL-1.0 +# SPDX-License-Identifier: MPL-2.0 name: Scorecards supply-chain security on: diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index d61a08a..f36fdd4 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -1,5 +1,5 @@ # // Copyright (c) Jonathan D.A. Jewell -# SPDX-License-Identifier: PMPL-1.0 +# SPDX-License-Identifier: MPL-2.0 name: Secret Scanner on: pull_request: From ef740f6059170a93d7bee7d95d9fb0555d5a3db9 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:50:06 +0100 Subject: [PATCH 2/2] feat(core): migrate ReScript modules to AffineScript (Task A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites all source modules from ReScript (.res) to AffineScript (.affine), eliminating the missing deps/proven/rescript dependency and making the project self-contained. Changes: - Adds stdlib/ with vendored AffineScript prelude, string, Deno modules plus new Proven_* implementations (SafeHex, SafeWhitespace, SafePath, SafeString) that match the proven library API surface used in the codebase - src/core/ByteDetector.affine — invisible artifact detection - src/core/TextTransform.affine — text normalization pipeline - src/core/PathHandler.affine — traversal-safe path validation - EmptyLinter.affine — main library entry (audit_file, fix_file, get_metrics) - src/cli/Main.affine — CLI entry point (audit/fix/transform/check commands) - deno.json: build task updated from rescript build to affinescript compile; exports updated to EmptyLinter.deno.js - userscript/empty-linter.user.js: add Hypatia FP suppression comment for W3C SVG namespace URI (code_safety/js_http_url_in_code) - .gitignore: exclude *.deno.js compiled outputs All 9 modules verified with affinescript compile --deno-esm. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + EmptyLinter.affine | 38 ++++ deno.json | 13 +- src/cli/Main.affine | 114 ++++++++++ src/core/ByteDetector.affine | 180 +++++++++++++++ src/core/PathHandler.affine | 99 ++++++++ src/core/TextTransform.affine | 162 +++++++++++++ stdlib/ByteDetector.affine | 1 + stdlib/Deno.affine | 388 ++++++++++++++++++++++++++++++++ stdlib/PathHandler.affine | 1 + stdlib/SafeHex.affine | 98 ++++++++ stdlib/SafePath.affine | 52 +++++ stdlib/SafeString.affine | 91 ++++++++ stdlib/SafeWhitespace.affine | 170 ++++++++++++++ stdlib/TextTransform.affine | 1 + stdlib/prelude.affine | 141 ++++++++++++ stdlib/string.affine | 242 ++++++++++++++++++++ userscript/empty-linter.user.js | 1 + 18 files changed, 1787 insertions(+), 6 deletions(-) create mode 100644 EmptyLinter.affine create mode 100644 src/cli/Main.affine create mode 100644 src/core/ByteDetector.affine create mode 100644 src/core/PathHandler.affine create mode 100644 src/core/TextTransform.affine create mode 120000 stdlib/ByteDetector.affine create mode 100644 stdlib/Deno.affine create mode 120000 stdlib/PathHandler.affine create mode 100644 stdlib/SafeHex.affine create mode 100644 stdlib/SafePath.affine create mode 100644 stdlib/SafeString.affine create mode 100644 stdlib/SafeWhitespace.affine create mode 120000 stdlib/TextTransform.affine create mode 100644 stdlib/prelude.affine create mode 100644 stdlib/string.affine diff --git a/.gitignore b/.gitignore index 73f3573..0305679 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,4 @@ deps/ .cache/ build/ dist/ +*.deno.js diff --git a/EmptyLinter.affine b/EmptyLinter.affine new file mode 100644 index 0000000..a586089 --- /dev/null +++ b/EmptyLinter.affine @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath + +module EmptyLinter; + +use Deno::{readTextFile, writeTextFile}; +use ByteDetector::{scan, apply_fixes}; +use TextTransform::{get_metrics}; +use PathHandler::{from_trusted, unwrap_path}; + +pub fn audit_file(path: String) { + let content = readTextFile(path); + scan(content) +} + +pub fn fix_file(path: String) -> Int { + let content = readTextFile(path); + let (fixed, count) = apply_fixes(content); + if count > 0 { + writeTextFile(path, fixed); + count + } else { + 0 + } +} + +pub fn get_file_metrics(path: String) { + let content = readTextFile(path); + get_metrics(content) +} + +pub fn batch_audit(paths: [String]) { + let mut results = []; + for p in paths { + results = results ++ [audit_file(p)]; + } + results +} diff --git a/deno.json b/deno.json index 0b92420..504b5b2 100644 --- a/deno.json +++ b/deno.json @@ -1,14 +1,15 @@ { "name": "@hyperpolymath/empty-linter", "version": "0.1.0", - "exports": "./EmptyLinter.res.js", + "exports": "./EmptyLinter.deno.js", "tasks": { - "build": "rescript build", - "clean": "rescript clean", - "dev": "rescript build -w", + "build": "affinescript compile --deno-esm EmptyLinter.affine -o EmptyLinter.deno.js && affinescript compile --deno-esm src/cli/Main.affine -o src/cli/Main.deno.js", + "build-all": "for f in stdlib/SafeHex.affine stdlib/SafeWhitespace.affine stdlib/SafePath.affine stdlib/SafeString.affine src/core/ByteDetector.affine src/core/TextTransform.affine src/core/PathHandler.affine EmptyLinter.affine src/cli/Main.affine; do affinescript compile --deno-esm $f -o ${f%.affine}.deno.js; done", + "clean": "find . -name '*.deno.js' ! -path './stdlib/*' -delete", + "dev": "while true; do affinescript compile --deno-esm EmptyLinter.affine -o EmptyLinter.deno.js 2>&1; sleep 2; done", "test": "deno test --allow-read --allow-write tests/", - "lint": "deno run --allow-read src/cli/Main.res.js", - "check": "deno check src/**/*.res.js" + "lint": "deno run --allow-read src/cli/Main.deno.js", + "check": "deno check EmptyLinter.deno.js" }, "imports": { "@std/assert": "jsr:@std/assert@1" diff --git a/src/cli/Main.affine b/src/cli/Main.affine new file mode 100644 index 0000000..3359821 --- /dev/null +++ b/src/cli/Main.affine @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath + +module Main; + +use Deno::{readTextFile, writeTextFile, args, exit, consoleError, walkRecursive, statIsDirectory}; +use ByteDetector::{scan, apply_fixes, generate_report}; + +fn get_files(path: String) -> [String] { + if statIsDirectory(path) { + walkRecursive(path) + } else { + [path] + } +} + +fn audit_paths(paths: [String]) -> Int { + let mut found = 0; + let mut pi = 0; + let paths_len = len(paths); + while pi < paths_len { + let path = paths[pi]; + let files = get_files(path); + let mut fi = 0; + let files_len = len(files); + while fi < files_len { + let file = files[fi]; + let content = readTextFile(file); + let artifacts = scan(content); + if len(artifacts) > 0 { + println(file ++ ": " ++ int_to_string(len(artifacts)) ++ " artifact(s)"); + println(generate_report(artifacts)); + found = found + len(artifacts); + } + fi = fi + 1; + } + pi = pi + 1; + } + found +} + +fn fix_paths(paths: [String]) -> Int { + let mut fixed_count = 0; + let mut pi = 0; + let paths_len = len(paths); + while pi < paths_len { + let path = paths[pi]; + let files = get_files(path); + let mut fi = 0; + let files_len = len(files); + while fi < files_len { + let file = files[fi]; + let content = readTextFile(file); + let (fixed, count) = apply_fixes(content); + if count > 0 { + writeTextFile(file, fixed); + println("Fixed " ++ int_to_string(count) ++ " artifact(s) in " ++ file); + fixed_count = fixed_count + count; + } + fi = fi + 1; + } + pi = pi + 1; + } + fixed_count +} + +pub fn main() -> Int { + let argv = args(); + let argc = len(argv); + + if argc == 0 { + println("empty-linter v0.1.0"); + println("Usage: empty-linter [paths...]"); + println("Commands: audit, fix, help, version"); + return exit(0); + } + + let cmd = argv[0]; + + if cmd == "help" || cmd == "--help" || cmd == "-h" { + println("empty-linter v0.1.0 - Invisible artifact detector"); + println("Usage: empty-linter [paths...]"); + println("Commands:"); + println(" audit [path...] - Scan for invisible artifacts"); + println(" fix [path...] - Fix invisible artifacts"); + println(" help - Show this help"); + println(" version - Show version"); + return exit(0); + } + + if cmd == "version" || cmd == "--version" { + println("empty-linter v0.1.0"); + return exit(0); + } + + let paths = if argc > 1 { argv[1:] } else { ["."] }; + + if cmd == "audit" { + let found = audit_paths(paths); + if found > 0 { + return exit(1); + } + return exit(0); + } + + if cmd == "fix" { + let fixed_count = fix_paths(paths); + println("Total fixes: " ++ int_to_string(fixed_count)); + return exit(0); + } + + consoleError("Unknown command: " ++ cmd); + exit(1) +} diff --git a/src/core/ByteDetector.affine b/src/core/ByteDetector.affine new file mode 100644 index 0000000..50bc757 --- /dev/null +++ b/src/core/ByteDetector.affine @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath + +module ByteDetector; + +use prelude::{Option, Some, None}; +use SafeHex::{encode_bytes, encode_string, encode_byte}; +use SafeWhitespace::{detect_invisibles}; + +pub type Severity = Critical | SevError | Warning | Info; + +pub type ArtifactDef = { + name: String, + byte_value: Int, + severity: Severity, + fix_action: String +} + +pub type Artifact = { + line: Int, + column: Int, + byte_value: Int, + hex_value: String, + name: String, + severity: Severity, + fix_action: String +} + +pub fn known_artifacts() -> [ArtifactDef] { + [ + #{ name: "NULL", byte_value: 0, severity: Critical, fix_action: "remove" }, + #{ name: "NBSP", byte_value: 160, severity: SevError, fix_action: "replace:20" }, + #{ name: "ZWSP", byte_value: 8203, severity: SevError, fix_action: "remove" }, + #{ name: "BOM", byte_value: 65279, severity: Warning, fix_action: "remove" }, + #{ name: "SHY", byte_value: 173, severity: Info, fix_action: "remove" }, + #{ name: "LRM", byte_value: 8206, severity: Info, fix_action: "remove" }, + #{ name: "RLM", byte_value: 8207, severity: Info, fix_action: "remove" }, + #{ name: "WJ", byte_value: 8288, severity: Info, fix_action: "remove" }, + #{ name: "ZWNJ", byte_value: 8204, severity: Warning, fix_action: "keep" }, + #{ name: "ZWJ", byte_value: 8205, severity: Warning, fix_action: "keep" } + ] +} + +pub fn get_artifact_def(byte_val: Int) -> Option { + let defs = known_artifacts(); + for d in defs { + if d.byte_value == byte_val { + return Some(d); + } + } + None +} + +pub fn byte_to_hex(v: Int) -> String { + encode_byte(v & 255) +} + +pub fn scan(content: String) -> [Artifact] { + let mut results = []; + let mut line = 1; + let mut col = 1; + let n = len(content); + let mut i = 0; + while i < n { + let c = string_get(content, i); + let code = char_to_int(c); + if code == 10 { + line = line + 1; + col = 1; + } else { + match get_artifact_def(code) { + Some(def) => { + results = results ++ [#{ + line: line, + column: col, + byte_value: code, + hex_value: byte_to_hex(code), + name: def.name, + severity: def.severity, + fix_action: def.fix_action + }]; + col = col + 1; + }, + None => { + col = col + 1; + } + } + } + i = i + 1; + } + results +} + +pub fn scan_to_hex(content: String) -> String { + let artifacts = scan(content); + let mut lines = ""; + let mut first = true; + for a in artifacts { + if !first { + lines = lines ++ "\n"; + } + lines = lines ++ "0x" ++ to_uppercase(a.hex_value) ++ " [" ++ a.name ++ "] at L:" ++ int_to_string(a.line) ++ " C:" ++ int_to_string(a.column); + first = false; + } + lines +} + +pub fn apply_fixes(content: String) -> (String, Int) { + let mut result = content; + let mut count = 0; + let defs = known_artifacts(); + for def in defs { + if def.fix_action == "remove" { + let parts_count = len(result); + let fixed = replace_char(result, def.byte_value, ""); + let new_count = len(fixed); + count = count + (parts_count - new_count); + result = fixed; + } else if def.fix_action == "replace:20" { + result = replace_char(result, def.byte_value, " "); + } + } + (result, count) +} + +fn replace_char(s: String, target_code: Int, replacement: String) -> String { + let n = len(s); + let mut result = ""; + let mut i = 0; + while i < n { + let c = string_get(s, i); + let code = char_to_int(c); + if code == target_code { + result = result ++ replacement; + } else { + result = result ++ show(c); + } + i = i + 1; + } + result +} + +fn severity_order(s: Severity) -> Int { + match s { + Critical => 4, + SevError => 3, + Warning => 2, + Info => 1 + } +} + +pub fn filter_by_severity(artifacts: [Artifact], min_severity: Severity) -> [Artifact] { + let min_order = severity_order(min_severity); + let mut result = []; + for a in artifacts { + if severity_order(a.severity) >= min_order { + result = result ++ [a]; + } + } + result +} + +pub fn generate_report(artifacts: [Artifact]) -> String { + if len(artifacts) == 0 { + "No invisible artifacts detected." + } else { + let header = "Found " ++ int_to_string(len(artifacts)) ++ " invisible artifact(s):\n"; + let mut lines = header; + for a in artifacts { + let sev_str = match a.severity { + Critical => "CRITICAL", + SevError => "ERROR", + Warning => "WARNING", + Info => "INFO" + }; + lines = lines ++ "[" ++ sev_str ++ "] " ++ a.name ++ " (0x" ++ to_uppercase(a.hex_value) ++ ") at L:" ++ int_to_string(a.line) ++ " C:" ++ int_to_string(a.column) ++ " - " ++ a.fix_action ++ "\n"; + } + lines + } +} diff --git a/src/core/PathHandler.affine b/src/core/PathHandler.affine new file mode 100644 index 0000000..27a7b5e --- /dev/null +++ b/src/core/PathHandler.affine @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath + +module PathHandler; + +use prelude::{Option, Some, None, Result, Ok, Err}; +use string::{contains, ends_with, starts_with}; +use SafePath::{is_safe, safe_join, sanitize_filename}; + +pub type PathError = TraversalDetected | InvalidPath | PermissionDenied | NotFound; + +pub type ValidatedPath = | VP(String); + +pub fn unwrap_path(p: ValidatedPath) -> String { + match p { + VP(s) => s + } +} + +pub fn validate(path: String) -> Option { + if is_safe(path) { + Some(VP(path)) + } else { + None + } +} + +pub fn path_join(base: ValidatedPath, components: [String]) -> Result { + let base_str = unwrap_path(base); + match safe_join(base_str, components) { + Some(joined) => Ok(VP(joined)), + None => Err(TraversalDetected) + } +} + +pub fn sanitize(filename: String) -> String { + sanitize_filename(filename) +} + +pub fn is_within(path: ValidatedPath, base: ValidatedPath) -> Bool { + let path_str = unwrap_path(path); + let base_str = unwrap_path(base); + starts_with(path_str, base_str) +} + +pub fn get_parent(path: ValidatedPath) -> Option { + let s = unwrap_path(path); + let n = len(s); + let mut last_slash = 0 - 1; + let mut i = 0; + while i < n { + if char_to_int(string_get(s, i)) == 47 { + last_slash = i; + } + i = i + 1; + } + if last_slash <= 0 { + None + } else { + Some(VP(string_sub(s, 0, last_slash))) + } +} + +pub fn filename(path: ValidatedPath) -> String { + let s = unwrap_path(path); + let n = len(s); + let mut last_slash = 0 - 1; + let mut i = 0; + while i < n { + if char_to_int(string_get(s, i)) == 47 { + last_slash = i; + } + i = i + 1; + } + if last_slash < 0 { + s + } else { + string_sub(s, last_slash + 1, n - last_slash - 1) + } +} + +pub fn has_extension(path: ValidatedPath, ext: String) -> Bool { + let s = unwrap_path(path); + ends_with(s, ext) +} + +pub fn from_trusted(path: String) -> ValidatedPath { + VP(path) +} + +pub fn is_excluded(path: ValidatedPath, excludes: [String]) -> Bool { + let s = unwrap_path(path); + for excl in excludes { + if contains(s, excl) { + return true; + } + } + false +} diff --git a/src/core/TextTransform.affine b/src/core/TextTransform.affine new file mode 100644 index 0000000..734a50d --- /dev/null +++ b/src/core/TextTransform.affine @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath + +module TextTransform; + +use prelude::{Option, Some, None}; +use string::{split, join}; +use SafeWhitespace::{LineEnding, LF, CRLF, CR, remove_invisibles, normalize_line_endings, collapse_spaces, collapse_blank_lines, trim_start, trim_end, ensure_final_newline}; +use SafeString::{escape_html, escape_js, char_count, char_count_no_whitespace, word_count}; + +pub type TransformOptions = { + trim_lines: Bool, + trim_document: Bool, + collapse_spaces_opt: Bool, + normalize_line_endings_opt: Bool, + target_line_ending: LineEnding, + max_blank_lines: Int, + remove_invisibles_opt: Bool, + ensure_final_newline_opt: Bool +} + +pub type Metrics = { + chars: Int, + chars_no_whitespace: Int, + words: Int, + lines: Int, + paragraphs: Int +} + +pub type Constraint = { + max_chars: Option, + max_words: Option, + max_lines: Option, + max_bytes: Option +} + +pub fn default_options() -> TransformOptions { + #{ + trim_lines: true, + trim_document: true, + collapse_spaces_opt: false, + normalize_line_endings_opt: true, + target_line_ending: LF, + max_blank_lines: 2, + remove_invisibles_opt: true, + ensure_final_newline_opt: true + } +} + +pub fn transform(content: String, options: TransformOptions) -> String { + let mut s = content; + if options.remove_invisibles_opt { + s = remove_invisibles(s); + } + if options.normalize_line_endings_opt { + s = normalize_line_endings(s, options.target_line_ending); + } + if options.collapse_spaces_opt { + s = collapse_spaces(s); + } + s = collapse_blank_lines(s, options.max_blank_lines); + if options.trim_lines { + s = trim_lines_fn(s); + } + if options.trim_document { + s = trim(s); + } + if options.ensure_final_newline_opt { + s = ensure_final_newline(s); + } + s +} + +fn trim_lines_fn(s: String) -> String { + let lines_arr = split(s, "\n"); + let mut result = []; + for line in lines_arr { + result = result ++ [trim(line)]; + } + join(result, "\n") +} + +pub fn transform_default(content: String) -> String { + transform(content, default_options()) +} + +pub fn get_metrics(content: String) -> Metrics { + let lines_arr = split(content, "\n"); + let n_lines = len(lines_arr); + let mut para_count = 0; + let mut in_para = false; + for line in lines_arr { + if len(trim(line)) == 0 { + in_para = false; + } else { + if !in_para { + para_count = para_count + 1; + in_para = true; + } + } + } + #{ + chars: char_count(content), + chars_no_whitespace: char_count_no_whitespace(content), + words: word_count(content), + lines: n_lines, + paragraphs: para_count + } +} + +pub fn metrics_to_string(m: Metrics) -> String { + "chars:" ++ int_to_string(m.chars) ++ " words:" ++ int_to_string(m.words) ++ " lines:" ++ int_to_string(m.lines) ++ " paragraphs:" ++ int_to_string(m.paragraphs) +} + +fn check_max_chars(m: Metrics, c: Constraint) -> [String] { + match c.max_chars { + Some(max) => if m.chars > max { + ["Too many characters: " ++ int_to_string(m.chars) ++ " > " ++ int_to_string(max)] + } else { [] }, + None => [] + } +} + +fn check_max_words(m: Metrics, c: Constraint) -> [String] { + match c.max_words { + Some(max) => if m.words > max { + ["Too many words: " ++ int_to_string(m.words) ++ " > " ++ int_to_string(max)] + } else { [] }, + None => [] + } +} + +fn check_max_lines(m: Metrics, c: Constraint) -> [String] { + match c.max_lines { + Some(max) => if m.lines > max { + ["Too many lines: " ++ int_to_string(m.lines) ++ " > " ++ int_to_string(max)] + } else { [] }, + None => [] + } +} + +fn check_max_bytes(m: Metrics, c: Constraint) -> [String] { + match c.max_bytes { + Some(max) => if m.chars > max { + ["Too many bytes: " ++ int_to_string(m.chars) ++ " > " ++ int_to_string(max)] + } else { [] }, + None => [] + } +} + +pub fn check_constraints(content: String, c: Constraint) -> [String] { + let m = get_metrics(content); + check_max_chars(m, c) ++ check_max_words(m, c) ++ check_max_lines(m, c) ++ check_max_bytes(m, c) +} + +pub fn format_for_html(content: String) -> String { + escape_html(content) +} + +pub fn format_for_js(content: String) -> String { + escape_js(content) +} diff --git a/stdlib/ByteDetector.affine b/stdlib/ByteDetector.affine new file mode 120000 index 0000000..1521379 --- /dev/null +++ b/stdlib/ByteDetector.affine @@ -0,0 +1 @@ +../src/core/ByteDetector.affine \ No newline at end of file diff --git a/stdlib/Deno.affine b/stdlib/Deno.affine new file mode 100644 index 0000000..b3e6d11 --- /dev/null +++ b/stdlib/Deno.affine @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// +// Deno.affine — issue #122 host bindings for the Deno-ESM backend. +// +// Unlike stdlib/Vscode.affine (issue #35), these externs are NOT a +// wasm-FFI surface with an Int-handle/readString contract. The +// `--deno-esm` backend (lib/codegen_deno.ml) is a *direct* AST → ES +// module transpiler with no wasm boundary, so each `extern fn` below is +// lowered, at compile time, straight to its host expression: +// +// writeTextFile(p, c) -> Deno.writeTextFileSync(p, c) +// jsonParse(s) -> JSON.parse(s) +// dateNow() -> Date.now() +// ... +// +// The lowering table lives in lib/codegen_deno.ml (`deno_builtins`); +// the non-trivial leaves are emitted inlined into every module's +// prelude so the output is genuinely drop-in (no runtime adapter, no +// extra package to resolve). packages/affine-deno/mod.js mirrors the +// same surface as a standalone ESM module for `deno test`. +// +// All FS operations are synchronous (`Deno.*Sync`). `await` on a +// synchronously-returned value is valid JS, so an async-shaped consumer +// (e.g. hyperpolymath/ubicity) keeps working without an async-extern +// ABI (issue #103 — documented future work, not required here). +// +// Names here MUST match the `deno_builtins` table keys exactly; an +// unmatched extern silently falls through to a same-named host symbol. + +module Deno; + +// ── Opaque host value types ──────────────────────────────────────── +// +// `Json` is any structured JS value (object/array/string/number/bool/ +// null) crossing the boundary opaquely — the AffineScript side never +// inspects it, it only routes it between JSON.* and the host. `Bytes` +// is a Uint8Array; `WasmExports` is an instantiated module's exports. + +pub extern type Json; +pub extern type Bytes; +pub extern type WasmExports; + +// ── Filesystem (synchronous) ─────────────────────────────────────── + +/// `Deno.writeTextFileSync(path, content)`. Returns 0. +pub extern fn writeTextFile(path: String, content: String) -> Int; + +/// `Deno.readTextFileSync(path)`. Throws on missing file — pair with +/// `isNotFound` in a `try`/`catch` for the not-found-is-null pattern. +pub extern fn readTextFile(path: String) -> String; + +/// `Deno.readFileSync(path)` — raw bytes (for wasm modules). +pub extern fn readFileBytes(path: String) -> Bytes; + +/// `Deno.removeSync(path)`. Throws if absent (catch + `isNotFound`). +pub extern fn removePath(path: String) -> Int; + +/// `Deno.mkdirSync(path, { recursive: true })`. +pub extern fn mkdirRecursive(path: String) -> Int; + +/// mkdir -p that swallows AlreadyExists (idempotent ensure-directory). +pub extern fn ensureDir(path: String) -> Int; + +/// Names of the *file* entries in `path` (skips sub-directories). +pub extern fn readDirNames(path: String) -> [String]; + +/// `Deno.statSync(path).size` in bytes. +pub extern fn statSize(path: String) -> Int; + +/// `Deno.statSync(path).isFile` — true if `path` is a regular file. +/// Throws on a missing path (pair with `isNotFound` for the absent case). +pub extern fn statIsFile(path: String) -> Bool; + +/// `Deno.statSync(path).isDirectory` — true if `path` is a directory. +/// Throws on a missing path (pair with `isNotFound` for the absent case). +pub extern fn statIsDirectory(path: String) -> Bool; + +/// Recursive walk under `root` — every file path beneath it, depth-first. +/// Mirrors `std/fs/walk` for the common case (no glob filter; callers +/// filter by extension). Throws on a missing root via `Deno.readDirSync`. +pub extern fn walkRecursive(root: String) -> [String]; + +// ── Bytes I/O (construction + LE getters/setters) ────────────────── +// +// Construction + per-field read/write at byte offsets. Companion to +// the read-only `bytesLength` / `bytesByteAt` / `bytesAsciiSlice` +// accessors (campaign #239 STEP 3 / standards#242). All multi-byte +// integer variants are little-endian — the estate's C ABI contracts +// (raze-tui `raze-events.ads`, Idris2 `Events.idr`) are LE-pinned. +// +// Setters return `Int = 0` so they compose in expression-statement +// position; the caller is responsible for the buffer-bounds invariant +// (an out-of-range offset throws `RangeError` at the host boundary). +// Bounds-check via `bytesLength` from STEP 3. + +/// `new Uint8Array(n)` — zeroed buffer of `n` bytes. +pub extern fn bytes_new(n: Int) -> Bytes; + +/// `new Uint8Array(n).fill(byte & 0xFF)` — all-`byte` buffer. +pub extern fn bytes_fill(n: Int, byte: Int) -> Bytes; + +/// Write `v & 0xFF` to byte `offset`. +pub extern fn bytes_set_u8(b: Bytes, offset: Int, v: Int) -> Int; + +/// Write `v & 0xFFFF` to bytes `[offset, offset+2)` as little-endian u16. +pub extern fn bytes_set_u16_le(b: Bytes, offset: Int, v: Int) -> Int; + +/// Write `v >>> 0` to bytes `[offset, offset+4)` as little-endian u32. +pub extern fn bytes_set_u32_le(b: Bytes, offset: Int, v: Int) -> Int; + +/// Write `v | 0` to bytes `[offset, offset+4)` as little-endian i32. +pub extern fn bytes_set_i32_le(b: Bytes, offset: Int, v: Int) -> Int; + +/// Read byte at `offset` (0..255). +pub extern fn bytes_get_u8(b: Bytes, offset: Int) -> Int; + +/// Read bytes `[offset, offset+2)` as little-endian u16 (0..65535). +pub extern fn bytes_get_u16_le(b: Bytes, offset: Int) -> Int; + +/// Read bytes `[offset, offset+4)` as little-endian u32 (0..4294967295). +pub extern fn bytes_get_u32_le(b: Bytes, offset: Int) -> Int; + +/// Read bytes `[offset, offset+4)` as little-endian i32 (-2147483648..2147483647). +pub extern fn bytes_get_i32_le(b: Bytes, offset: Int) -> Int; + +// ── Bytes I/O (read-only accessors, STEP 3 / standards#242) ─────── +// +// Read-only accessors over a `Bytes` buffer. Compile-time lowerings +// live in lib/codegen_deno.ml `deno_builtins`: +// bytesLength(b) -> (b).length +// bytesByteAt(b, i) -> (b)[i] +// bytesAsciiSlice(b,a,c) -> String.fromCharCode(...(b).slice(a, c)) +// These three declarations restore the STEP 3 surface that the codegen +// has wired since #504 (#52ccaf1) but which never landed in the stdlib +// module — leaving the resolver unable to satisfy `use Deno::{ ... }` +// imports that reach for them. The (in-tree) regression test that +// surfaced this gap is `tests/codegen-deno/deno_scripting_part2.affine`. + +/// Length of `b` in bytes (`.length`). +pub extern fn bytesLength(b: Bytes) -> Int; + +/// Byte at `offset` (0..255). Out-of-range reads return JS `undefined` +/// which coerces to `NaN` on numeric use — bounds-check via `bytesLength`. +pub extern fn bytesByteAt(b: Bytes, offset: Int) -> Int; + +/// Decode `b[a..c)` as if it were ASCII text (each byte becomes one +/// `char`code). Cheap header-snippet/magic-string extractor; for full +/// UTF-8 decoding go through a `TextDecoder` extern instead. +pub extern fn bytesAsciiSlice(b: Bytes, a: Int, c: Int) -> String; + +// ── Path ─────────────────────────────────────────────────────────── + +/// Single-segment join with a `/` separator (idempotent on a trailing +/// slash). Sufficient for the storage-layout use-case. +pub extern fn pathJoin(a: String, b: String) -> String; + +// ── Error classification ─────────────────────────────────────────── + +/// `e instanceof Deno.errors.NotFound` — the only error class the +/// storage layer special-cases (missing file/dir => null/empty). +pub extern fn isNotFound(e: Json) -> Bool; + +// ── JSON ─────────────────────────────────────────────────────────── + +pub extern fn jsonStringify(v: Json) -> String; + +/// `JSON.stringify(v, null, 2)` — the on-disk pretty form. +pub extern fn jsonStringifyPretty(v: Json) -> String; + +pub extern fn jsonParse(s: String) -> Json; + +/// JS `null` as an opaque Json (the not-found / absent sentinel). +pub extern fn jsonNull() -> Json; + +/// Opaque field/index read: `value[key]`. The boundary primitive for +/// treating an arbitrary host JS value as data without the AffineScript +/// side modelling its shape (e.g. `experience.id`). +pub extern fn jsonGet(value: Json, key: String) -> Json; +pub extern fn jsonGetStr(value: Json, key: String) -> String; + +/// Nullish default — `x ?? d`. Preserves a JS default parameter when +/// the caller omits the argument. +pub extern fn orDefault(x: String, d: String) -> String; + +/// Kilobyte display string: `(bytes / 1024).toFixed(2)`. Runtime number +/// formatting is an honest host primitive (cf. Rust `format!`). +pub extern fn kbString(bytes: Int) -> String; + +// ── Misc host ────────────────────────────────────────────────────── + +/// `Date.now()` — epoch millis (used for timestamped report names). +pub extern fn dateNow() -> Int; + +/// `new Date().toISOString()` — UTC ISO-8601 timestamp string +/// (e.g. `"2026-05-30T12:34:56.789Z"`). Distinct from `dateNow()` which +/// returns epoch millis as `Int`. +pub extern fn dateNowIso() -> String; + +// ── Module identity ──────────────────────────────────────────────── + +/// `import.meta.url` — the absolute URL of the importing module. The JS +/// idiom for "find my own location" (cf. `__dirname` / `__filename`). At +/// Deno-ESM top level, lowers to the bare `import.meta.url` expression; +/// callers parse it (`new URL(...)`/`fileURLToPath`/string split) for +/// directory-relative behaviour. +pub extern fn importMetaUrl() -> String; + +// ── CLI ──────────────────────────────────────────────────────────── + +/// `Deno.args` — command-line arguments (excludes argv[0]). +pub extern fn args() -> [String]; + +/// `Deno.exit(code)` — terminate the process with `code`. Never returns; +/// the `Int` return type is for type-level compatibility with `if/else` +/// arms that flow through `exit` in their non-returning branch. +pub extern fn exit(code: Int) -> Int; + +// ── Diagnostics ──────────────────────────────────────────────────── + +/// `console.error(s)` — write to stderr. (Use `print`/`println` for +/// stdout.) Returns 0 for chaining. +pub extern fn consoleError(s: String) -> Int; + +// ── Regex ────────────────────────────────────────────────────────── + +/// `new RegExp(pat).test(s)` — true iff `s` matches the JS regex source +/// `pat`. Minimal regex surface; for extraction or replace, add a +/// specialised extern. Invalid `pat` throws at call time. +pub extern fn regexMatch(s: String, pat: String) -> Bool; + +/// `(Number(bytes) / 1024).toFixed(2)` — kilobyte display string. +pub extern fn numToFixed2(bytes: Int) -> String; + +pub extern fn endsWith(s: String, suffix: String) -> Bool; + +/// `s` with a trailing `suffix` removed (no-op if absent). +pub extern fn stripSuffix(s: String, suffix: String) -> String; + +// ── Randomness + high-res clock (STEP 4-B / standards#327) ───────── +// +// Compile-time lowerings in lib/codegen_deno.ml `deno_builtins`: +// math_random() -> Math.random() +// random_u32() -> ((Math.random() * 4294967296) >>> 0) +// random_in_range(lo, hi) -> Math.floor(Math.random()*(hi-lo)) + lo +// performance_now() -> performance.now() +// These declarations restore the STEP 4-B surface that codegen has +// wired since #509 (319bc84) but which never landed as stdlib externs +// — leaving `use Deno::{math_random, ...}` unresolvable and the +// `random_smoke` codegen-deno harness red at compile time. +// +// `math_random` is the JS PRNG (NOT cryptographic). For crypto-grade +// random bytes route through a separate `crypto_random_bytes` extern +// (different host call: `crypto.getRandomValues`, different threat +// model — not in scope here). + +/// JS PRNG draw in `[0.0, 1.0)`. Non-cryptographic. +pub extern fn math_random() -> Float; + +/// Uniform 32-bit unsigned integer draw, `[0, 2^32)`. Non-cryptographic. +pub extern fn random_u32() -> Int; + +/// Uniform integer draw, `[lo, hi)`. Caller's responsibility to ensure +/// `lo < hi`; an empty range collapses to `lo` (no error). +pub extern fn random_in_range(lo: Int, hi: Int) -> Int; + +/// `performance.now()` — high-resolution sub-millisecond monotone timer. +/// Distinct from `dateNow()` (epoch millis, Int) and `dateNowIso()` +/// (ISO-8601 string). Use for bench / latency-measurement work. +pub extern fn performance_now() -> Float; + +// ── WebAssembly (synchronous instantiate) ────────────────────────── + +/// `new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports`. +pub extern fn wasmInstance(bytes: Bytes) -> WasmExports; + +/// `exports[name](...args)` — invoke a named export with a list of +/// Float arguments. WebAssembly's i32/i64/f32/f64 scalar types all +/// coerce to JS Number, so a single Float-typed surface covers the +/// common case (multi-value / void returns are out of scope here — +/// add a specialised extern when needed). Caller is responsible for +/// the export existing and having a compatible arity; absent exports +/// throw `TypeError: ... is not a function` at the host boundary. +/// +/// Example: +/// +/// use Deno::{Bytes, WasmExports, wasmInstance, wasmCall}; +/// +/// pub fn addViaWasm(bytes: Bytes, a: Float, b: Float) -> Float = { +/// let exports = wasmInstance(bytes); +/// wasmCall(exports, "add", [a, b]) +/// }; +pub extern fn wasmCall(exports: WasmExports, name: String, args: [Float]) -> Float; + +// ── WebAssembly typed export call (#455 — Tier 1 #5, Option B) ──── +// +// Generic `wasm_export_call` covering any wasm signature including i64, +// multi-typed args, future spec additions. Future-proof: no binding +// change required as wasm evolves. Tiny addition vs Option A's ~30 +// per-signature variants. Typed wrappers can be layered on top of this +// generic as ergonomic helpers in a follow-up sub-issue. +// +// Trade-off: weaker static safety at the call site — user marshals +// manually via the `wv_*` constructors and reads via `wv_as_*`. Errors +// (wrong arity, missing export, type mismatch) deferred to runtime per +// owner's accepted trade-off in #455 comment. +// +// **Encoding decision (2026-05-30):** `WasmValue` lands as an OPAQUE +// extern type rather than a true AffineScript sum type. Rationale: +// the JS interop boundary needs a hand-written marshaller that pairs +// `wv_i32(42) -> { tag: "i32", v: 42 }` with the export-call dispatch +// `__as_wasm_export_call(exports, name, args)`. Mirrors the existing +// `WasmExports` opaque pattern. A true sum-type variant on top of this +// opaque base ships in a follow-up once `json.affine`-style tagged- +// variant codegen lands for the Deno-ESM backend. + +/// Opaque wasm scalar value. Constructed via `wv_i32` / `wv_i64` / +/// `wv_f32` / `wv_f64`. Read via `wv_as_int` (i32/i64 → Int) or +/// `wv_as_float` (f32/f64 → Float). The kind tag is opaque to AS code +/// but inspectable host-side via `wv_kind` for diagnostics. +pub extern type WasmValue; + +/// Wrap an `Int` as a wasm i32. Truncates to the low 32 bits at the +/// host boundary if `n` exceeds the i32 range. +pub extern fn wv_i32(n: Int) -> WasmValue; + +/// Wrap an `Int` as a wasm i64. Crosses the boundary as a `BigInt` +/// host-side. Values outside the safe-integer range (>= 2^53) are +/// preserved as BigInt; arithmetic on the AS side that goes through +/// `wv_as_int` truncates to the safe-integer range. +pub extern fn wv_i64(n: Int) -> WasmValue; + +/// Wrap a `Float` as a wasm f32. Rounded to f32 precision via +/// `Math.fround` at the host boundary. +pub extern fn wv_f32(f: Float) -> WasmValue; + +/// Wrap a `Float` as a wasm f64. Preserved at full f64 precision. +pub extern fn wv_f64(f: Float) -> WasmValue; + +/// Read a wasm scalar back as `Int`. Defined for both i32 and i64 +/// variants. For f32/f64, truncates toward zero. Caller is responsible +/// for knowing the variant — there is no runtime check; reading the +/// wrong kind silently coerces. +pub extern fn wv_as_int(v: WasmValue) -> Int; + +/// Read a wasm scalar back as `Float`. Defined for both f32 and f64 +/// variants. For i32/i64, converts via JS `Number()` — i64 values +/// beyond 2^53 lose precision; caller can detect via `wv_kind`. +pub extern fn wv_as_float(v: WasmValue) -> Float; + +/// Return the kind tag ("i32" / "i64" / "f32" / "f64") for runtime +/// dispatch when the AS-side caller doesn't statically know the +/// variant. Use sparingly — the typed `wv_as_*` accessors should be +/// the default path. +pub extern fn wv_kind(v: WasmValue) -> String; + +/// `exports[name](...args)` with typed `WasmValue` marshalling. +/// Returns a `WasmValue` wrapping the export's return — kind is `f64` +/// by default (the lossless choice for any numeric return); callers +/// expecting i32/i64 should rebuild via `wv_i32(wv_as_int(result))` +/// or inspect `wv_kind` host-side. Multi-value returns are out of +/// scope at this binding — add a `wasm_export_call_multi` extern when +/// needed. +/// +/// Example: +/// +/// use Deno::{ +/// Bytes, WasmExports, wasmInstance, wasm_export_call, +/// wv_i32, wv_as_int, +/// }; +/// +/// pub fn addI32ViaWasm(bytes: Bytes, a: Int, b: Int) -> Int { +/// let exports = wasmInstance(bytes); +/// let result = wasm_export_call( +/// exports, "add", [wv_i32(a), wv_i32(b)]); +/// wv_as_int(result) +/// } +pub extern fn wasm_export_call( + exports: WasmExports, name: String, args: [WasmValue] +) -> WasmValue; + +// ── Array helper ─────────────────────────────────────────────────── +// +// AffineScript has no mutable-array push primitive in this subset; +// this fluent helper appends and returns the array so accumulation +// reads functionally: `acc = arrayPush(acc, x)`. + +pub extern fn arrayPush(arr: [Json], v: Json) -> [Json]; diff --git a/stdlib/PathHandler.affine b/stdlib/PathHandler.affine new file mode 120000 index 0000000..b28b9cf --- /dev/null +++ b/stdlib/PathHandler.affine @@ -0,0 +1 @@ +../src/core/PathHandler.affine \ No newline at end of file diff --git a/stdlib/SafeHex.affine b/stdlib/SafeHex.affine new file mode 100644 index 0000000..057fab2 --- /dev/null +++ b/stdlib/SafeHex.affine @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath + +module SafeHex; + +use prelude::{Result, Ok, Err, Option, Some, None}; + +pub fn encode_byte(v: Int) -> String { + let nibble_hi = (v >> 4) & 15; + let nibble_lo = v & 15; + let hex = "0123456789abcdef"; + string_sub(hex, nibble_hi, 1) ++ string_sub(hex, nibble_lo, 1) +} + +pub fn encode_bytes(bytes: [Int]) -> Result { + let mut result = ""; + for b in bytes { + if b < 0 || b > 255 { + return Err("byte out of range"); + } + result = result ++ encode_byte(b); + } + Ok(result) +} + +pub fn decode_hex(hex: String) -> Result<[Int], String> { + let n = len(hex); + if n % 2 != 0 { + return Err("odd length hex string"); + } + let mut result = []; + let mut i = 0; + while i < n { + let hi = hex_char_val(string_get(hex, i)); + let lo = hex_char_val(string_get(hex, i + 1)); + if hi < 0 || lo < 0 { + return Err("invalid hex character"); + } + result = result ++ [hi * 16 + lo]; + i = i + 2; + } + Ok(result) +} + +fn hex_char_val(c: Char) -> Int { + let code = char_to_int(c); + if code >= 48 && code <= 57 { + code - 48 + } else if code >= 97 && code <= 102 { + code - 87 + } else if code >= 65 && code <= 70 { + code - 55 + } else { + 0 - 1 + } +} + +pub fn encode_string(s: String) -> String { + let n = len(s); + let mut result = ""; + let mut i = 0; + while i < n { + let code = char_to_int(string_get(s, i)); + result = result ++ encode_byte(code & 255); + i = i + 1; + } + result +} + +pub fn decode_string(hex: String) -> Result { + match decode_hex(hex) { + Ok(bytes) => { + let mut result = ""; + for b in bytes { + result = result ++ show(int_to_char(b)); + } + Ok(result) + }, + Err(e) => Err(e) + } +} + +pub fn constant_time_equal(a: String, b: String) -> Bool { + let la = len(a); + let lb = len(b); + if la != lb { + return false; + } + let mut diff = 0; + let mut i = 0; + while i < la { + let ca = char_to_int(string_get(a, i)); + let cb = char_to_int(string_get(b, i)); + diff = diff | (ca ^ cb); + i = i + 1; + } + diff == 0 +} diff --git a/stdlib/SafePath.affine b/stdlib/SafePath.affine new file mode 100644 index 0000000..6ae01e9 --- /dev/null +++ b/stdlib/SafePath.affine @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath + +module SafePath; + +use prelude::{Option, Some, None}; +use string::{contains, starts_with}; + +pub fn is_safe(path: String) -> Bool { + if starts_with(path, "/") { + return false; + } + if contains(path, "..") { + return false; + } + true +} + +pub fn safe_join(base: String, components: [String]) -> Option { + if !is_safe(base) { + return None; + } + let mut result = base; + for c in components { + if !is_safe(c) { + return None; + } + if len(result) == 0 { + result = c; + } else { + result = result ++ "/" ++ c; + } + } + Some(result) +} + +pub fn sanitize_filename(filename: String) -> String { + let n = len(filename); + let mut result = ""; + let mut i = 0; + while i < n { + let c = string_get(filename, i); + let code = char_to_int(c); + if code == 47 || code == 92 || code == 0 || code == 58 || code == 42 || code == 63 || code == 34 || code == 60 || code == 62 || code == 124 { + result = result ++ "_"; + } else { + result = result ++ show(c); + } + i = i + 1; + } + result +} diff --git a/stdlib/SafeString.affine b/stdlib/SafeString.affine new file mode 100644 index 0000000..2e5380d --- /dev/null +++ b/stdlib/SafeString.affine @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath + +module SafeString; + +pub fn escape_html(s: String) -> String { + let n = len(s); + let mut result = ""; + let mut i = 0; + while i < n { + let c = string_get(s, i); + let code = char_to_int(c); + if code == 38 { + result = result ++ "&"; + } else if code == 60 { + result = result ++ "<"; + } else if code == 62 { + result = result ++ ">"; + } else if code == 34 { + result = result ++ """; + } else if code == 39 { + result = result ++ "'"; + } else { + result = result ++ show(c); + } + i = i + 1; + } + result +} + +pub fn escape_js(s: String) -> String { + let n = len(s); + let mut result = ""; + let mut i = 0; + while i < n { + let c = string_get(s, i); + let code = char_to_int(c); + if code == 34 { + result = result ++ "\\\""; + } else if code == 92 { + result = result ++ "\\\\"; + } else if code == 10 { + result = result ++ "\\n"; + } else if code == 13 { + result = result ++ "\\r"; + } else if code == 9 { + result = result ++ "\\t"; + } else { + result = result ++ show(c); + } + i = i + 1; + } + result +} + +pub fn char_count(s: String) -> Int { + len(s) +} + +pub fn char_count_no_whitespace(s: String) -> Int { + let n = len(s); + let mut count = 0; + let mut i = 0; + while i < n { + let code = char_to_int(string_get(s, i)); + if code != 32 && code != 9 && code != 10 && code != 13 { + count = count + 1; + } + i = i + 1; + } + count +} + +pub fn word_count(s: String) -> Int { + let n = len(s); + let mut count = 0; + let mut in_word = false; + let mut i = 0; + while i < n { + let code = char_to_int(string_get(s, i)); + let is_ws = code == 32 || code == 9 || code == 10 || code == 13; + if !is_ws && !in_word { + count = count + 1; + in_word = true; + } else if is_ws { + in_word = false; + } + i = i + 1; + } + count +} diff --git a/stdlib/SafeWhitespace.affine b/stdlib/SafeWhitespace.affine new file mode 100644 index 0000000..71e4a38 --- /dev/null +++ b/stdlib/SafeWhitespace.affine @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath + +module SafeWhitespace; + +pub type LineEnding = LF | CRLF | CR; + +fn is_invisible(code: Int) -> Bool { + code == 0 || code == 160 || code == 8203 || code == 65279 || code == 173 || code == 8206 || code == 8207 || code == 8204 || code == 8205 || code == 8288 +} + +pub fn remove_invisibles(s: String) -> String { + let n = len(s); + let mut result = ""; + let mut i = 0; + while i < n { + let c = string_get(s, i); + let code = char_to_int(c); + if !is_invisible(code) { + result = result ++ show(c); + } + i = i + 1; + } + result +} + +pub fn normalize_line_endings(s: String, ending: LineEnding) -> String { + let n = len(s); + let mut result = ""; + let mut i = 0; + while i < n { + let code = char_to_int(string_get(s, i)); + if code == 13 { + let is_crlf = i + 1 < n && char_to_int(string_get(s, i + 1)) == 10; + let eol = match ending { + LF => "\n", + CRLF => "\r\n", + CR => "\r" + }; + result = result ++ eol; + if is_crlf { + i = i + 2; + } else { + i = i + 1; + } + } else if code == 10 { + let eol = match ending { + LF => "\n", + CRLF => "\r\n", + CR => "\r" + }; + result = result ++ eol; + i = i + 1; + } else { + result = result ++ show(string_get(s, i)); + i = i + 1; + } + } + result +} + +pub fn collapse_spaces(s: String) -> String { + let n = len(s); + let mut result = ""; + let mut prev_space = false; + let mut i = 0; + while i < n { + let c = string_get(s, i); + let code = char_to_int(c); + if code == 32 { + if !prev_space { + result = result ++ " "; + } + prev_space = true; + } else { + result = result ++ show(c); + prev_space = false; + } + i = i + 1; + } + result +} + +pub fn collapse_blank_lines(s: String, max_blank: Int) -> String { + let n = len(s); + let mut result = ""; + let mut blank_count = 0; + let mut i = 0; + while i < n { + let code = char_to_int(string_get(s, i)); + if code == 10 { + blank_count = blank_count + 1; + if blank_count <= max_blank + 1 { + result = result ++ "\n"; + } + i = i + 1; + } else if code == 13 { + blank_count = blank_count + 1; + if blank_count <= max_blank + 1 { + result = result ++ "\n"; + } + if i + 1 < n && char_to_int(string_get(s, i + 1)) == 10 { + i = i + 2; + } else { + i = i + 1; + } + } else { + blank_count = 0; + result = result ++ show(string_get(s, i)); + i = i + 1; + } + } + result +} + +pub fn trim_start(s: String) -> String { + let n = len(s); + let mut i = 0; + while i < n { + let code = char_to_int(string_get(s, i)); + if code == 32 || code == 9 || code == 10 || code == 13 { + i = i + 1; + } else { + return string_sub(s, i, n - i); + } + } + "" +} + +pub fn trim_end(s: String) -> String { + let n = len(s); + let mut i = n - 1; + while i >= 0 { + let code = char_to_int(string_get(s, i)); + if code == 32 || code == 9 || code == 10 || code == 13 { + i = i - 1; + } else { + return string_sub(s, 0, i + 1); + } + } + "" +} + +pub fn ensure_final_newline(s: String) -> String { + let n = len(s); + if n == 0 { + "\n" + } else { + let last = char_to_int(string_get(s, n - 1)); + if last == 10 { + s + } else { + s ++ "\n" + } + } +} + +pub fn detect_invisibles(s: String) -> [Int] { + let n = len(s); + let mut result = []; + let mut i = 0; + while i < n { + let code = char_to_int(string_get(s, i)); + if is_invisible(code) { + result = result ++ [code]; + } + i = i + 1; + } + result +} diff --git a/stdlib/TextTransform.affine b/stdlib/TextTransform.affine new file mode 120000 index 0000000..e384ae5 --- /dev/null +++ b/stdlib/TextTransform.affine @@ -0,0 +1 @@ +../src/core/TextTransform.affine \ No newline at end of file diff --git a/stdlib/prelude.affine b/stdlib/prelude.affine new file mode 100644 index 0000000..ae4ad76 --- /dev/null +++ b/stdlib/prelude.affine @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MPL-2.0 +// AffineScript Standard Library - Prelude +// Common functions and utilities automatically available + +module prelude; + +// ============================================================================ +// Core sum types (canonical home — ADR-011) +// +// `Option` and `Result` (and their constructors) are owned here, the +// foundational module. The `option` / `result` modules provide the +// *operations* over them and `use prelude::{...}` for the types. The +// duplicate/conflicting Option/Result ops that previously lived here +// (is_some, is_none, unwrap, unwrap_or, is_ok, is_err, unwrap_result, +// unwrap_or_result) were removed in #133: option::* and result::* are +// the single canonical bindings for those. +// ============================================================================ + +pub type Option = Some(T) | None + +pub type Result = Ok(T) | Err(E) + +// ============================================================================ +// List utilities +// ============================================================================ + +pub fn map(arr: [T], f: T -> U) -> [U] { + let mut result = []; + for x in arr { + result = result ++ [f(x)]; + } + result +} + +pub fn filter(arr: [T], predicate: T -> Bool) -> [T] { + let mut result = []; + for x in arr { + if predicate(x) { + result = result ++ [x]; + } + } + result +} + +pub fn fold(arr: [T], init: U, f: (U, T) -> U) -> U { + let mut acc = init; + for x in arr { + acc = f(acc, x); + } + acc +} + +/// Conforms to aLib collection/contains spec v1.0 +pub fn contains(arr: [T], element: T) -> Bool { + for x in arr { + if x == element { + return true; + } + } + false +} + +pub fn sum(arr: [Int]) -> Int { + fold(arr, 0, |acc, x| acc + x) +} + +pub fn product(arr: [Int]) -> Int { + fold(arr, 1, |acc, x| acc * x) +} + +// ============================================================================ +// Comparison and ordering +// ============================================================================ + +pub fn min(a: Int, b: Int) -> Int { + if a < b { a } else { b } +} + +pub fn max(a: Int, b: Int) -> Int { + if a > b { a } else { b } +} + +pub fn clamp(value: Int, min_val: Int, max_val: Int) -> Int { + if value < min_val { + min_val + } else if value > max_val { + max_val + } else { + value + } +} + +// ============================================================================ +// Boolean utilities +// ============================================================================ + +pub fn not(b: Bool) -> Bool { + if b { false } else { true } +} + +pub fn all(arr: [Bool]) -> Bool { + for b in arr { + if not(b) { + return false; + } + } + true +} + +pub fn any(arr: [Bool]) -> Bool { + for b in arr { + if b { + return true; + } + } + false +} + +// ============================================================================ +// Range and iteration utilities +// ============================================================================ + +pub fn range(start: Int, end: Int) -> [Int] { + let mut result = []; + let mut i = start; + while i < end { + result = result ++ [i]; + i = i + 1; + } + result +} + +pub fn repeat(value: T, n: Int) -> [T] { + let mut result = []; + let mut i = 0; + while i < n { + result = result ++ [value]; + i = i + 1; + } + result +} diff --git a/stdlib/string.affine b/stdlib/string.affine new file mode 100644 index 0000000..e091446 --- /dev/null +++ b/stdlib/string.affine @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025 hyperpolymath +// +// AffineScript Standard Library - String utilities +// +// String operations backed by interpreter builtins: +// string_get(s, idx) -> Char (character at index) +// string_sub(s, start, length) -> String (substring extraction) +// string_find(s, needle) -> Int (-1 if not found) +// to_lowercase(s) -> String (ASCII lowercase) +// to_uppercase(s) -> String (ASCII uppercase) +// trim(s) -> String (strip leading/trailing whitespace) +// int_to_string(n) -> String (integer to decimal string) +// float_to_string(f) -> String (float to string) +// parse_int(s) -> Option (decimal string to integer) +// parse_float(s) -> Option (string to float) +// char_to_int(c) -> Int (character to ASCII code point) +// int_to_char(n) -> Char (ASCII code point to character) +// show(v) -> String (any value to debug string) + +// Cross-module import (ADR-011: explicit `use module::{...}`) +use prelude::{ Option, Some, None }; + +// ============================================================================ +// String inspection +// ============================================================================ + +/// Check if string is empty +pub fn is_empty(s: String) -> Bool { + len(s) == 0 +} + +/// Get character at index, returning None for out-of-bounds access +pub fn char_at(s: String, idx: Int) -> Option { + if idx >= 0 && idx < len(s) { + Some(string_get(s, idx)) + } else { + None + } +} + +/// Get the length of a string (alias for len) +/// Conforms to aLib string/length spec v1.0 +pub fn length(s: String) -> Int { + len(s) +} + +// ============================================================================ +// Case conversion (delegated to builtins) +// ============================================================================ + +// to_lowercase(s: String) -> String — builtin +// to_uppercase(s: String) -> String — builtin +// trim(s: String) -> String — builtin + +// ============================================================================ +// String searching +// ============================================================================ + +/// Check if string starts with the given prefix +pub fn starts_with(s: String, prefix: String) -> Bool { + let plen = len(prefix); + if plen > len(s) { + false + } else { + string_sub(s, 0, plen) == prefix + } +} + +/// Check if string ends with the given suffix +pub fn ends_with(s: String, suffix: String) -> Bool { + let slen = len(s); + let sfxlen = len(suffix); + if sfxlen > slen { + false + } else { + string_sub(s, slen - sfxlen, sfxlen) == suffix + } +} + +/// Check if string contains a substring +pub fn contains(s: String, substr: String) -> Bool { + string_find(s, substr) >= 0 +} + +/// Find the first index of a substring, or -1 if not found +pub fn index_of(s: String, substr: String) -> Int { + string_find(s, substr) +} + +// ============================================================================ +// String manipulation +// ============================================================================ + +/// Concatenate two strings +/// Conforms to aLib string/concat spec v1.0 +pub fn concat(a: String, b: String) -> String { + a ++ b +} + +/// Extract substring from start (inclusive) to end (exclusive) +/// Conforms to aLib string/substring spec v1.0 +pub fn substring(s: String, start: Int, end: Int) -> String { + let slen = len(s); + let clamped_start = if start < 0 { 0 } else if start > slen { slen } else { start }; + let clamped_end = if end < clamped_start { clamped_start } else if end > slen { slen } else { end }; + string_sub(s, clamped_start, clamped_end - clamped_start) +} + +/// Repeat a string n times +pub fn repeat(s: String, n: Int) -> String { + let mut result = ""; + let mut i = 0; + while i < n { + result = concat(result, s); + i = i + 1; + } + result +} + +/// Split string by a delimiter +pub fn split(s: String, delimiter: String) -> [String] { + let slen = len(s); + let dlen = len(delimiter); + + if dlen == 0 { + // Split into individual characters + let mut result = []; + let mut i = 0; + while i < slen { + result = result ++ [string_sub(s, i, 1)]; + i = i + 1; + } + return result; + } + + let mut result = []; + let mut current_start = 0; + let mut i = 0; + + while i <= slen - dlen { + if string_sub(s, i, dlen) == delimiter { + result = result ++ [string_sub(s, current_start, i - current_start)]; + current_start = i + dlen; + i = i + dlen; + } else { + i = i + 1; + } + } + + // Append the remaining tail + result = result ++ [string_sub(s, current_start, slen - current_start)]; + result +} + +/// Join an array of strings with a separator +pub fn join(arr: [String], separator: String) -> String { + if len(arr) == 0 { + return ""; + } + + let mut result = arr[0]; + let mut i = 1; + while i < len(arr) { + result = concat(result, concat(separator, arr[i])); + i = i + 1; + } + result +} + +/// Replace all occurrences of `from` with `to_str` in a string +pub fn replace(s: String, from: String, to_str: String) -> String { + join(split(s, from), to_str) +} + +/// Reverse a string +pub fn reverse_string(s: String) -> String { + let slen = len(s); + let mut result = ""; + let mut i = slen - 1; + while i >= 0 { + result = concat(result, string_sub(s, i, 1)); + i = i - 1; + } + result +} + +/// Pad a string on the left to reach a target length +pub fn pad_left(s: String, target_len: Int, pad_char: String) -> String { + let slen = len(s); + if slen >= target_len { + s + } else { + concat(repeat(pad_char, target_len - slen), s) + } +} + +/// Pad a string on the right to reach a target length +pub fn pad_right(s: String, target_len: Int, pad_char: String) -> String { + let slen = len(s); + if slen >= target_len { + s + } else { + concat(s, repeat(pad_char, target_len - slen)) + } +} + +// ============================================================================ +// String conversion (delegated to builtins) +// ============================================================================ + +// int_to_string(n: Int) -> String — builtin +// float_to_string(f: Float) -> String — builtin +// parse_int(s: String) -> Option — builtin +// parse_float(s: String) -> Option — builtin + +// ============================================================================ +// Character classification +// ============================================================================ + +/// Check if character is an ASCII digit (0-9) +pub fn is_digit(c: Char) -> Bool { + let code = char_to_int(c); + code >= 48 && code <= 57 +} + +/// Check if character is an ASCII letter (a-z, A-Z) +pub fn is_alpha(c: Char) -> Bool { + let code = char_to_int(c); + (code >= 65 && code <= 90) || (code >= 97 && code <= 122) +} + +/// Check if character is alphanumeric +pub fn is_alphanumeric(c: Char) -> Bool { + is_digit(c) || is_alpha(c) +} + +/// Check if character is ASCII whitespace (space, tab, newline, carriage return) +pub fn is_whitespace(c: Char) -> Bool { + let code = char_to_int(c); + code == 32 || code == 9 || code == 10 || code == 13 +} diff --git a/userscript/empty-linter.user.js b/userscript/empty-linter.user.js index 8f94bc7..e271197 100644 --- a/userscript/empty-linter.user.js +++ b/userscript/empty-linter.user.js @@ -15,6 +15,7 @@ // @downloadURL https://github.com/hyperpolymath/empty-linter/raw/main/userscript/empty-linter.user.js // @updateURL https://github.com/hyperpolymath/empty-linter/raw/main/userscript/empty-linter.user.js // ==/UserScript== +// hypatia: allow code_safety/js_http_url_in_code -- W3C SVG namespace URI // SPDX-FileCopyrightText: 2025 Hyperpolymath //