diff --git a/.github/badges/api-color.svg b/.github/badges/api-color.svg index 0c8c249..52685f5 100644 --- a/.github/badges/api-color.svg +++ b/.github/badges/api-color.svg @@ -1,14 +1,14 @@ - - color: 20/24 (83%) + + color: 24/24 (100%) - + \ No newline at end of file diff --git a/.github/badges/api-coverage.svg b/.github/badges/api-coverage.svg index c22654b..2cc15e4 100644 --- a/.github/badges/api-coverage.svg +++ b/.github/badges/api-coverage.svg @@ -1,5 +1,5 @@ - - API coverage: 81% + + API coverage: 83% @@ -7,8 +7,8 @@ \ No newline at end of file diff --git a/.github/badges/api-types.svg b/.github/badges/api-types.svg index e9afafa..3c471c5 100644 --- a/.github/badges/api-types.svg +++ b/.github/badges/api-types.svg @@ -1,5 +1,5 @@ - - types: 166/180 (92%) + + types: 177/180 (98%) @@ -7,8 +7,8 @@ \ No newline at end of file diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg index 03b8d7f..c12b430 100644 --- a/.github/badges/coverage.svg +++ b/.github/badges/coverage.svg @@ -1,20 +1,20 @@ - - test coverage: 87.8% - + + test coverage: 88.5% + - - + + - + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f3af4..d73cf65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # Change Log +## [0.9.4] - 2026-03-11 - Color Namespace, Transpiler Overhaul, request.security & Drawing Improvements + +### Added + +- **`color` Namespace Refactor**: Extracted the full color implementation from `Core.ts` into a dedicated `src/namespaces/color/PineColor.ts` module. Adds complete `COLOR_CONSTANTS` (all named palette colors), improved hex/rgb/rgba/`#RRGGBBAA` parsing, `color.from_gradient()` with NaN guard, and a full test suite. +- **`alert()` Stub**: Added the missing `alert()` function (previously only `alertcondition` existed). Emits to the context event bus so downstream code can subscribe without crashing. +- **`max_bars_back()` No-Op**: Added `max_bars_back(source, length)` as a compatibility stub. Returns its source argument unchanged (PineTS maintains full history, so there is no lookback cap to configure). +- **`linefill` Instance Methods**: `LinefillObject` now exposes `get_line1()`, `get_line2()`, and `set_color()` directly on the instance, enabling UDT field-chain patterns like `myStruct.fill.set_color(c)`. +- **UDT `.new()` Named Arguments**: `MyType.new(field1=val1, field2=val2)` now works correctly. The UDT constructor detects a named-argument object and maps keys to fields instead of positional assignment. +- **`linefill.new` Thunking**: Added `linefill.new` to `FACTORY_METHODS` so it receives the arrow-function thunk treatment in `var` declarations, preventing orphaned linefill objects from being created on every bar. +- **`math.__neq()` — Inequality Operator**: Added `math.__neq(a, b)` to handle Pine Script's `!=` / `<>` operator with proper NaN semantics (mirrors `math.__eq`). + +### Fixed + +#### Transpiler + +- **For-Loop Init & Update `$.get()` Wrapping**: The for-loop init and update expressions lacked `addArrayAccess`, `MemberExpression`, and `CallExpression` handlers. Series variables appearing in loop bounds (e.g., `for i = 0 to bar_index - 1`) were left as raw Series objects, causing the update ternary to evaluate to `NaN` and producing infinite loops or bodies that never executed. +- **While-Loop Test Condition**: `while bar_index > cnt` and similar conditions with Series variables were not wrapped in `$.get()`, so the comparison always evaluated against a raw Series object (→ `NaN`). Fixed by adding missing `addArrayAccess` and namespace-object skip logic to `transformWhileStatement`. +- **Function-Scoped Variable Resolution**: Added `isVariableInFunctionScope()` to `ScopeManager`. `createScopedVariableReference()` now correctly resolves `var` declarations inside nested `if`/`for` blocks *within* functions to the local context (`$$`) instead of the global context (`$`). +- **Optional Chaining for `na` UDT Drawing Fields**: `hasGetCallInChain()` now traverses `MemberExpression` *and* intermediate `CallExpression` nodes to detect `$.get()` in deeper chains. Inserts `?.` on the final method call so `myStruct.line?.set_x2(x)` does not crash when the field is `na`/`undefined`. +- **User Function vs Method Call Disambiguation**: Added `isChainedPropertyMethod` guard — when the callee object is itself a `MemberExpression` (e.g., `myObj.x.set()`), the call is not mistakenly redirected through `$.call()` even if `set` happens to be a user-defined function name. Added `_skipTransformation = true` on function-reference identifiers inside `$.call()` to prevent them from resolving to same-named variables. +- **`hasGetCallInChain()` Chain Expression Traversal**: Extended to walk through `ChainExpression` wrapper nodes (`?.` optional chains) so already-wrapped intermediate nodes are also checked when determining whether to insert optional chaining. +- **`ReturnStatement` Walk-Through**: `MainTransformer`'s `ReturnStatement` handler now recurses into complex return arguments when not in function scope, preventing untransformed expressions in nested return statements. +- **`parseArgsForPineParams` NaN Handling**: Fixed dynamic Pine Script signatures passing `NaN` values through the argument normalizer, which caused downstream `isNaN` checks to misidentify valid numeric `0` values. +- **Await Propagation in User-Defined Functions**: Functions containing `request.security` calls (which are async internally) now correctly propagate `async`/`await` through the function declaration, preventing unresolved Promise objects from reaching callers. +- **Tuple Destructuring in User Functions**: Fixed the Pine Script parser emitting single-bracket `[a, b]` returns instead of the required double-bracket `[[a, b]]` tuple form when `=>` arrow functions ended with an `if/else` that returned a tuple. +- **Function Parameter Namespace Collision Renaming**: Parameters whose names collide with built-in namespaces (e.g., a parameter named `color`) were being looked up as namespace objects instead of local variables. The transpiler now renames such parameters to avoid the collision. +- **ArrayExpression Function Parameter Scoping**: Function parameters used inside array literal arguments (e.g., `[output, ...]`) were incorrectly resolved to the global scope (`$.let.output`) instead of the local raw identifier (`output`). Added `isLocalSeriesVar` check in `ExpressionTransformer`. +- **Switch Statement Tuple Destructuring**: IIFE array returns inside switch branches were not wrapped in the required `[[a, b, c]]` double-bracket form, causing `$.init()` to treat the tuple as a time-series and extract only the last element. +- **Array/Matrix Typed Declarations**: The Pine Script parser now correctly parses `array`, `matrix`, and other generic typed declarations in variable declarations and function signatures. Strong-typing tests cover all primitive and object element types. + +#### Runtime + +- **`plotcandle` and `barcolor`**: Fixed incorrect argument mapping and color resolution in both functions. `barcolor` now correctly applies per-bar color overrides to the candlestick series, and `plotcandle` produces properly structured OHLC plot data. +- **`request.security` Expression Handling**: Complex expressions passed as the `expression` argument (not just simple identifiers or plot references) now evaluate correctly in the secondary context. Also fixed user-defined method expressions being passed across context boundaries. +- **`request.security_lower_tf` Pine Script Behavior**: Rewrote lower-timeframe (LTF) aggregation to match TradingView's behavior — values are collected as intra-bar arrays, and the correct array element (first vs. last vs. all) is returned depending on `lookahead` / `gaps` settings. +- **Normalized Timeframes**: `timeframe.in_seconds()` and related utilities now correctly handle all non-canonical formats (`'1h'`→`'60'`, `'1d'`→`'D'`, `'1w'`→`'W'`) and return `NaN`/`0` when given `undefined` or an unrecognised string. +- **Plot Color Change Detection**: Fixed false positives in the plot color-change detector that caused unnecessary re-renders when the color value was numerically identical but represented by different intermediate Series wrappers. +- **`str.split()` Returns Pine Array**: `str.split()` was returning a plain JavaScript array. It now returns a `PineArrayObject` so array namespace methods (`.get()`, `.size()`, etc.) work on the result. +- **Label Colors & Backgrounds**: Fixed `label.set_textcolor()` and `label.set_bgcolor()` not applying when called after construction, and resolved parsing inconsistencies in `parseArgsForPineParams` that treated valid color `0` as `na`. +- **`color.from_gradient` NaN Guard**: Added `null`/`NaN`/`undefined` guards for all five arguments; previously a missing value produced `#NANNANNAN` hex strings. +- **Improved Color Parsing**: `PineColor` now handles all Pine Script color representations uniformly: 6-digit hex, 8-digit hex (`#RRGGBBAA`), `rgb()`, `rgba()`, named constants, and `color.new()` output. +- **Polyline Rendering Fixes**: Fixed `polyline.new()` crash when `points` contained `na` entries, incorrect `xloc` handling for bar-index vs. time coordinates, and missing default line/fill colors. +- **Array `new_*` Capacity Handling**: `array.new(size, initial)` variants now clamp the requested capacity to `MAX_ARRAY_SIZE` and correctly initialise all elements to the provided default (was previously initialising to `undefined` in some typed constructors). +- **Table Cell Null Guard**: `table.cell()` now guards against `null`/`undefined` row or column indices, preventing a crash when table access patterns involve conditional creation. +- **`chart.fg_color`**: Fixed `chart.fg_color` returning the wrong value (`bg_color` was returned for both properties due to a copy-paste error). +- **Default Colors for Polyline and Table**: `polyline.new()` and `table.new()` no longer require explicit color arguments; sensible defaults are applied when colors are omitted or `na`. +- **User Functions Treated as Native Functions**: Fixed a regression where user-defined functions registered in `settings.ts` were forwarded through the native namespace dispatcher instead of the user function call path. +- **Sourcemap Generation for Browser Dev Build**: Fixed the rollup sourcemap pipeline for the `build:dev:browser` target so browser DevTools correctly resolve transpiled runtime errors to TypeScript source lines. + +--- + ## [0.9.3] - 2026-03-06 - Streaming Support, request.security Fixes, Transpiler Robustness ### Added diff --git a/docs/api-coverage/color.md b/docs/api-coverage/color.md index 9cd7fc8..a629065 100644 --- a/docs/api-coverage/color.md +++ b/docs/api-coverage/color.md @@ -35,7 +35,7 @@ parent: API Coverage | `color.from_gradient()` | ✅ | Create color from gradient | | `color.new()` | ✅ | Create new color | | `color.rgb()` | ✅ | Create color from RGB values | -| `color.b()` | | Get blue component | -| `color.g()` | | Get green component | -| `color.r()` | | Get red component | -| `color.t()` | | Get transparency component | +| `color.b()` | ✅ | Get blue component | +| `color.g()` | ✅ | Get green component | +| `color.r()` | ✅ | Get red component | +| `color.t()` | ✅ | Get transparency component | diff --git a/docs/api-coverage/pinescript-v6/color.json b/docs/api-coverage/pinescript-v6/color.json index a5329cf..0086191 100644 --- a/docs/api-coverage/pinescript-v6/color.json +++ b/docs/api-coverage/pinescript-v6/color.json @@ -19,12 +19,12 @@ "color.yellow": true }, "Color Functions": { - "color.b()": false, + "color.b()": true, "color.from_gradient()": true, - "color.g()": false, + "color.g()": true, "color.new()": true, - "color.r()": false, + "color.r()": true, "color.rgb()": true, - "color.t()": false + "color.t()": true } -} \ No newline at end of file +} diff --git a/docs/api-coverage/pinescript-v6/types.json b/docs/api-coverage/pinescript-v6/types.json index c5ab518..27f7c8d 100644 --- a/docs/api-coverage/pinescript-v6/types.json +++ b/docs/api-coverage/pinescript-v6/types.json @@ -173,14 +173,14 @@ "position.top_right": true }, "scale": { - "scale.left": false, - "scale.none": false, - "scale.right": false + "scale.left": true, + "scale.none": true, + "scale.right": true }, "settlement_as_close": { - "settlement_as_close.inherit": false, - "settlement_as_close.off": false, - "settlement_as_close.on": false + "settlement_as_close.inherit": true, + "settlement_as_close.off": true, + "settlement_as_close.on": true }, "shape": { "shape.arrowdown": true, @@ -209,14 +209,14 @@ "splits.numerator": true }, "text": { - "text.align_bottom": false, + "text.align_bottom": true, "text.align_center": true, "text.align_left": true, "text.align_right": true, - "text.align_top": false, - "text.format_bold": false, - "text.format_italic": false, - "text.format_none": false, + "text.align_top": true, + "text.format_bold": true, + "text.format_italic": true, + "text.format_none": true, "text.wrap_auto": true, "text.wrap_none": true }, diff --git a/docs/api-coverage/types.md b/docs/api-coverage/types.md index 8a30116..e74dacc 100644 --- a/docs/api-coverage/types.md +++ b/docs/api-coverage/types.md @@ -179,17 +179,17 @@ parent: API Coverage | Function | Status | Description | | ------------- | ------ | ----------- | -| `scale.left` | | Left scale | -| `scale.none` | | No scale | -| `scale.right` | | Right scale | +| `scale.left` | ✅ | Left scale | +| `scale.none` | ✅ | No scale | +| `scale.right` | ✅ | Right scale | ### Settlement_as_close | Function | Status | Description | | ----------------------------- | ------ | --------------------------- | -| `settlement_as_close.inherit` | | Inherit settlement as close | -| `settlement_as_close.off` | | Settlement as close off | -| `settlement_as_close.on` | | Settlement as close on | +| `settlement_as_close.inherit` | ✅ | Inherit settlement as close | +| `settlement_as_close.off` | ✅ | Settlement as close off | +| `settlement_as_close.on` | ✅ | Settlement as close on | ### Shape @@ -230,14 +230,14 @@ parent: API Coverage | Function | Status | Description | | -------------------- | ------ | --------------------- | -| `text.align_bottom` | | Bottom text alignment | +| `text.align_bottom` | ✅ | Bottom text alignment | | `text.align_center` | ✅ | Center text alignment | | `text.align_left` | ✅ | Left text alignment | | `text.align_right` | ✅ | Right text alignment | -| `text.align_top` | | Top text alignment | -| `text.format_bold` | | Bold text format | -| `text.format_italic` | | Italic text format | -| `text.format_none` | | No text format | +| `text.align_top` | ✅ | Top text alignment | +| `text.format_bold` | ✅ | Bold text format | +| `text.format_italic` | ✅ | Italic text format | +| `text.format_none` | ✅ | No text format | | `text.wrap_auto` | ✅ | Auto text wrap | | `text.wrap_none` | ✅ | No text wrap | diff --git a/package.json b/package.json index e345514..75b3ae2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pinets", - "version": "0.9.3", + "version": "0.9.4", "description": "Run Pine Script anywhere. PineTS is an open-source transpiler and runtime that brings Pine Script logic to Node.js and the browser with 1:1 syntax compatibility. Reliably write, port, and run indicators or strategies on your own infrastructure.", "keywords": [ "Pine Script", diff --git a/rollup.config.js b/rollup.config.js index a099864..2ec6be4 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -110,6 +110,7 @@ const BrowserConfigDev = { format: 'umd', name: 'PineTSLib', exports: 'auto', + sourcemap: true, }, plugins: [ excludeMockProvider(), diff --git a/scripts/generate-matrix-index.js b/scripts/generate-matrix-index.js index 378dadf..74c94d6 100644 --- a/scripts/generate-matrix-index.js +++ b/scripts/generate-matrix-index.js @@ -133,6 +133,11 @@ ${getters.map((g) => ` ${g}: ${g}`).join(',\n')} }) .join('\n'); + // Type-specific aliases for matrix.new() → matrix.new_TYPE() + // The transpiler rewrites generic type params to method name suffixes + const typeAliases = ['float', 'int', 'string', 'bool', 'color', 'line', 'label', 'box', 'linefill', 'table']; + const typeAliasInstall = typeAliases.map(t => ` this.new_${t} = new_fn(context);`).join('\n'); + const classCode = `// SPDX-License-Identifier: AGPL-3.0-only // This file is auto-generated. Do not edit manually. // Run: npm run generate:matrix-index @@ -146,6 +151,9 @@ export class PineMatrix { ${getterInstall} // Install methods ${methodInstall} + // Type-specific aliases — used internally by PineTS to handle strong types. + // The transpiler rewrites matrix.new(...) → matrix.new_float(...) +${typeAliasInstall} } } diff --git a/src/Context.class.ts b/src/Context.class.ts index 5405567..44c3bf2 100644 --- a/src/Context.class.ts +++ b/src/Context.class.ts @@ -7,6 +7,7 @@ import { PineMap } from './namespaces/map/map.index'; import { PineMatrix } from './namespaces/matrix/matrix.index'; import { Barstate } from './namespaces/Barstate'; import { Core, NAHelper } from './namespaces/Core'; +import { PineColor } from './namespaces/color/PineColor'; import { TimeHelper, TimeComponentHelper, EXTRACTORS, getDatePartsInTimezone } from './namespaces/Time'; import { Input } from './namespaces/input/input.index'; import PineMath from './namespaces/math/math.index'; @@ -142,12 +143,14 @@ export class Context { Type: core.Type.bind(core), na: new NAHelper(), - color: core.color, nz: core.nz.bind(core), indicator: core.indicator.bind(core), fixnan: core.fixnan.bind(core), alertcondition: core.alertcondition.bind(core), + alert: core.alert.bind(core), + error: core.error.bind(core), + max_bars_back: core.max_bars_back.bind(core), timestamp: core.timestamp.bind(core), time: new TimeHelper(this, 'openTime'), time_close: new TimeHelper(this, 'closeTime'), @@ -457,6 +460,41 @@ export class Context { Object.defineProperty(this.pine['table'], 'all', { get: () => tableHelper.all, }); + + // color namespace + const colorHelper = new PineColor(this); + this.bindContextObject( + colorHelper, + [ + 'any', + 'param', + 'new', + 'rgb', + 'from_gradient', + 'r', + 'g', + 'b', + 't', + 'aqua', + 'black', + 'blue', + 'fuchsia', + 'gray', + 'green', + 'lime', + 'maroon', + 'navy', + 'olive', + 'orange', + 'purple', + 'red', + 'silver', + 'teal', + 'white', + 'yellow', + ], + 'color', + ); } /** diff --git a/src/PineTS.class.ts b/src/PineTS.class.ts index ba59176..e30bd9b 100644 --- a/src/PineTS.class.ts +++ b/src/PineTS.class.ts @@ -342,6 +342,12 @@ export class PineTS { // #4: Data changed — bump version so secondary contexts know to refresh context.dataVersion++; + // Update context.length so barstate.islast (which checks + // context.idx === context.length - 1) works correctly for new bars. + // Without this, barstate.islast stays false after new candles arrive, + // and any `if barstate.islast` drawing logic never executes. + context.length = this.data.length; + // Always recalculate last candle + new ones // Remove last result (will be recalculated with fresh data) this._removeLastResult(context); diff --git a/src/namespaces/Barstate.ts b/src/namespaces/Barstate.ts index 5832447..9042322 100644 --- a/src/namespaces/Barstate.ts +++ b/src/namespaces/Barstate.ts @@ -20,11 +20,15 @@ export class Barstate { } public get ishistory() { - return this.context.idx < this.context.data.close.data.length - 1; + // Use context.length (total bar count) instead of incrementally-built + // context.data.close.data.length, which only has bars 0..idx during + // execution and would always equal idx+1 (making ishistory always false). + return this.context.idx < this.context.length - 1; } public get isrealtime() { - return this.context.idx === this.context.data.close.data.length - 1; + // Use context.length for same reason as ishistory above. + return this.context.idx === this.context.length - 1; } public get isconfirmed() { @@ -36,10 +40,36 @@ export class Barstate { } public get islastconfirmedhistory() { - // True when this is the last bar whose close time is in the past - // (the bar right before the current live bar). - const closeTime = this.context.data.closeTime.data[this.context.idx]; - const nextCloseTime = this.context.data.closeTime.data[this.context.idx + 1]; - return closeTime <= Date.now() && (nextCloseTime === undefined || nextCloseTime > Date.now()); + // True on exactly ONE bar: the last confirmed historical bar. + // Per Pine Script docs: "Returns true if script is executing on the + // dataset's last bar when market is closed, or on the bar immediately + // preceding the real-time bar if market is open." + // + // Uses context.length (total bar count, set before iteration) instead + // of the incrementally-built context.data arrays, which only contain + // bars 0..idx during execution and would falsely return true on every bar. + const idx = this.context.idx; + const totalBars = this.context.length; + + if (idx === totalBars - 1) { + // Last bar in the dataset — true only if market is closed + // (i.e., this bar's close time is in the past → it's confirmed) + const closeTime = this.context.data.closeTime.data[idx]; + return closeTime <= Date.now(); + } + + if (idx === totalBars - 2) { + // Second-to-last bar — true if the last bar is a live/realtime bar + // (i.e., the last bar's close time is still in the future). + // Read from context.marketData (full raw candle array, available + // before iteration starts) to peek at the last bar's close time. + const lastCloseTime = this.context.marketData?.[totalBars - 1]?.closeTime; + if (lastCloseTime !== undefined) { + return lastCloseTime > Date.now(); + } + return false; + } + + return false; } } diff --git a/src/namespaces/Core.ts b/src/namespaces/Core.ts index 2055962..17de399 100644 --- a/src/namespaces/Core.ts +++ b/src/namespaces/Core.ts @@ -62,74 +62,6 @@ const INDICATOR_ARGS_TYPES = { behind_chart: 'boolean', }; -/** - * Parse any color string (#hex, #hexAA, rgb(), rgba()) into [r, g, b, a] with a in 0..1. - * Returns null if unparsable. - */ -function parseColorToRGBA(color: string): [number, number, number, number] | null { - if (!color || typeof color !== 'string') return null; - - // #RRGGBB or #RRGGBBAA - if (color.startsWith('#')) { - const hex = color.slice(1); - if (hex.length === 6) { - return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16), 1]; - } - if (hex.length === 8) { - return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16), parseInt(hex.slice(6, 8), 16) / 255]; - } - return null; - } - - // rgba(r, g, b, a) or rgb(r, g, b) - const rgbaMatch = color.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)/); - if (rgbaMatch) { - return [parseInt(rgbaMatch[1]), parseInt(rgbaMatch[2]), parseInt(rgbaMatch[3]), rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1]; - } - - return null; -} - -/** - * Convert [r, g, b, a] back to a hex string. If a < 1, include the alpha byte. - */ -function rgbaToHex(r: number, g: number, b: number, a: number): string { - const rr = Math.round(Math.max(0, Math.min(255, r))) - .toString(16) - .padStart(2, '0'); - const gg = Math.round(Math.max(0, Math.min(255, g))) - .toString(16) - .padStart(2, '0'); - const bb = Math.round(Math.max(0, Math.min(255, b))) - .toString(16) - .padStart(2, '0'); - if (a >= 1) return `#${rr}${gg}${bb}`.toUpperCase(); - const aa = Math.round(Math.max(0, Math.min(255, a * 255))) - .toString(16) - .padStart(2, '0'); - return `#${rr}${gg}${bb}${aa}`.toUpperCase(); -} - -const COLOR_CONSTANTS = { - aqua: '#00BCD4', - black: '#363A45', - blue: '#2196F3', - fuchsia: '#E040FB', - gray: '#787B86', - green: '#4CAF50', - lime: '#00E676', - maroon: '#880E4F', - navy: '#311B92', - olive: '#808000', - orange: '#FF9800', - purple: '#9C27B0', - red: '#F23645', - silver: '#B2B5BE', - teal: '#089981', - white: '#FFFFFF', - yellow: '#FDD835', -} as const; - export function parseIndicatorOptions(args: any[]): Partial { return parseArgsForPineParams>(args, INDICATOR_SIGNATURE, INDICATOR_ARGS_TYPES); } @@ -160,95 +92,6 @@ export class NAHelper { } export class Core { - public color = { - param: (source, index = 0) => { - return Series.from(source).get(index); - }, - rgb: (r: number, g: number, b: number, a?: number) => (a ? `rgba(${r}, ${g}, ${b}, ${(100 - a) / 100})` : `rgb(${r}, ${g}, ${b})`), - new: (color: any, a?: number) => { - // Resolve bound functions (e.g. chart.fg_color, chart.bg_color) - if (typeof color === 'function') color = color(); - // Resolve Series objects - if (color && typeof color === 'object' && Array.isArray(color.data) && typeof color.get === 'function') { - color = color.get(0); - } - // Ensure color is a string - if (!color || typeof color !== 'string') return color; - - // Handle hexadecimal colors - if (color.startsWith('#')) { - // Remove # and convert to RGB - const hex = color.slice(1); - return a - ? `#${hex}${Math.round((255 / 100) * (100 - a)) - .toString(16) - .padStart(2, '0') - .toUpperCase()}` - : `#${hex}`; - } else { - const hex = COLOR_CONSTANTS[color]; - return hex - ? a - ? `#${hex}${Math.round((255 / 100) * (100 - a)) - .toString(16) - .padStart(2, '0') - .toUpperCase()}` - : `#${hex}` - : a - ? `rgba(${color}, ${(100 - a) / 100})` - : color; // Handle existing RGB format - } - }, - from_gradient: (value: any, bottom_value: any, top_value: any, bottom_color: any, top_color: any): string => { - // Resolve Series/functions for all args - if (typeof value === 'function') value = value(); - if (typeof bottom_value === 'function') bottom_value = bottom_value(); - if (typeof top_value === 'function') top_value = top_value(); - if (typeof bottom_color === 'function') bottom_color = bottom_color(); - if (typeof top_color === 'function') top_color = top_color(); - if (value && typeof value === 'object' && typeof value.get === 'function') value = value.get(0); - if (bottom_value && typeof bottom_value === 'object' && typeof bottom_value.get === 'function') bottom_value = bottom_value.get(0); - if (top_value && typeof top_value === 'object' && typeof top_value.get === 'function') top_value = top_value.get(0); - if (bottom_color && typeof bottom_color === 'object' && typeof bottom_color.get === 'function') bottom_color = bottom_color.get(0); - if (top_color && typeof top_color === 'object' && typeof top_color.get === 'function') top_color = top_color.get(0); - - // Clamp position between 0 and 1 - let t = 0; - if (top_value !== bottom_value) { - t = (value - bottom_value) / (top_value - bottom_value); - } - t = Math.max(0, Math.min(1, t)); - - // Parse both colors to RGBA - const bc = parseColorToRGBA(typeof bottom_color === 'string' ? bottom_color : '#000000') || [0, 0, 0, 1]; - const tc = parseColorToRGBA(typeof top_color === 'string' ? top_color : '#FFFFFF') || [255, 255, 255, 1]; - - // Linear interpolation - const r = bc[0] + (tc[0] - bc[0]) * t; - const g = bc[1] + (tc[1] - bc[1]) * t; - const b = bc[2] + (tc[2] - bc[2]) * t; - const a = bc[3] + (tc[3] - bc[3]) * t; - - return rgbaToHex(r, g, b, a); - }, - aqua: COLOR_CONSTANTS['aqua'], - black: COLOR_CONSTANTS['black'], - blue: COLOR_CONSTANTS['blue'], - fuchsia: COLOR_CONSTANTS['fuchsia'], - gray: COLOR_CONSTANTS['gray'], - green: COLOR_CONSTANTS['green'], - lime: COLOR_CONSTANTS['lime'], - maroon: COLOR_CONSTANTS['maroon'], - navy: COLOR_CONSTANTS['navy'], - olive: COLOR_CONSTANTS['olive'], - orange: COLOR_CONSTANTS['orange'], - purple: COLOR_CONSTANTS['purple'], - red: COLOR_CONSTANTS['red'], - silver: COLOR_CONSTANTS['silver'], - teal: COLOR_CONSTANTS['teal'], - white: COLOR_CONSTANTS['white'], - yellow: COLOR_CONSTANTS['yellow'], - }; constructor(private context: any) {} private extractPlotOptions(options: PlotCharOptions) { const _options: any = {}; @@ -310,6 +153,16 @@ export class Core { alertcondition(condition, title, message) { //console.warn('alertcondition called but is currently not implemented', condition, title, message); } + alert(...args: any[]) { + console.warn('alert called but is currently not implemented', args); + } + error(...args: any[]) { + console.error('error called but is currently not implemented', args); + } + max_bars_back(series?: any, length?: any) { + // No-op in PineTS — Pine Script uses this to hint the runtime about + // how many historical bars a series needs. PineTS keeps full history. + } /** * Converts date/time components to a UNIX timestamp in milliseconds. @@ -483,9 +336,32 @@ export class Core { new: function (...args: any[]) { // Map positional args to field names, applying defaults for missing args const mappedArgs: Record = {}; + + // Detect named args object: if the first (and only) argument is a plain + // object whose keys match field names, treat it as named arguments. + // This handles the Pine pattern: MyType.new(field1 = val1, field2 = val2) + // which the transpiler converts to: MyType.new({ field1: val1, field2: val2 }) + let namedArgs: Record | null = null; + if ( + args.length === 1 && + args[0] && + typeof args[0] === 'object' && + !(args[0] instanceof Series) && + !Array.isArray(args[0]) && + !(args[0] instanceof PineTypeObject) + ) { + const keys = Object.keys(args[0]); + if (keys.length > 0 && keys.some((k) => definitionKeys.includes(k))) { + namedArgs = args[0]; + args = []; // Clear positional args + } + } + for (let i = 0; i < definitionKeys.length; i++) { const key = definitionKeys[i]; - if (i < args.length) { + if (namedArgs && key in namedArgs) { + mappedArgs[key] = namedArgs[key]; + } else if (i < args.length) { mappedArgs[key] = args[i]; } else if (key in fieldDefaults) { // Evaluate default at construction time — handles series references diff --git a/src/namespaces/Plots.ts b/src/namespaces/Plots.ts index 56b2374..9053309 100644 --- a/src/namespaces/Plots.ts +++ b/src/namespaces/Plots.ts @@ -60,14 +60,14 @@ const PLOT_ARGS_TYPES = { //prettier-ignore const PLOT_SHAPE_ARGS_TYPES = { series: 'series', title: 'string', style: 'string', location: 'string', - color: 'string', offset: 'number', text: 'string', textcolor: 'string', + color: 'color', offset: 'number', text: 'string', textcolor: 'color', editable: 'boolean', size: 'string', show_last: 'number', display: 'string', format: 'string', precision: 'number', force_overlay: 'boolean', }; //prettier-ignore const PLOT_ARROW_ARGS_TYPES = { - series: 'series', title: 'string', colorup: 'string', colordown: 'string', + series: 'series', title: 'string', colorup: 'color', colordown: 'color', offset: 'number', minheight: 'number', maxheight: 'number', editable: 'boolean', show_last: 'number', display: 'string', format: 'string', precision: 'number', force_overlay: 'boolean', @@ -76,39 +76,39 @@ const PLOT_ARROW_ARGS_TYPES = { //prettier-ignore const PLOTBAR_ARGS_TYPES = { open: 'series', high: 'series', low: 'series', close: 'series', - title: 'string', color: 'string', editable: 'boolean', show_last: 'number', display: 'string', + title: 'string', color: 'color', editable: 'boolean', show_last: 'number', display: 'string', format: 'string', precision: 'number', force_overlay: 'boolean', }; //prettier-ignore const PLOTCANDLE_ARGS_TYPES = { open: 'series', high: 'series', low: 'series', close: 'series', - title: 'string', color: 'string', wickcolor: 'string', bordercolor: 'string', + title: 'string', color: 'color', wickcolor: 'color', bordercolor: 'color', editable: 'boolean', show_last: 'number', display: 'string', format: 'string', precision: 'number', force_overlay: 'boolean', }; //prettier-ignore const BGCOLOR_ARGS_TYPES = { - color: 'string', offset: 'number', editable: 'boolean', show_last: 'number', + color: 'color', offset: 'number', editable: 'boolean', show_last: 'number', title: 'string', display: 'string', force_overlay: 'boolean', }; //prettier-ignore const BARCOLOR_ARGS_TYPES = { - color: 'string', offset: 'number', editable: 'boolean', show_last: 'number', + color: 'color', offset: 'number', editable: 'boolean', show_last: 'number', title: 'string', display: 'string', }; //prettier-ignore const HLINE_ARGS_TYPES = { - price: 'series', title: 'string', color: 'string', linestyle: 'string', linewidth: 'number', + price: 'series', title: 'string', color: 'color', linestyle: 'string', linewidth: 'number', editable: 'boolean', display: 'string' }; //prettier-ignore const FILL_ARGS_TYPES = { - plot1: 'object', plot2: 'object', color: 'string', title: 'string', editable: 'boolean', show_last: 'number', fillgaps: 'boolean', display: 'string', + plot1: 'object', plot2: 'object', color: 'color', title: 'string', editable: 'boolean', show_last: 'number', fillgaps: 'boolean', display: 'string', }; export class PlotHelper { @@ -500,6 +500,10 @@ export class FillHelper { fillKey = callsiteId; } + // Resolve the color for this bar. + // The color may be a Series, a param tuple [value, name], or a plain string. + const resolvedColor = Series.from(color).get(0); + if (!this.context.plots[fillKey]) { const p1Key = plot1?._plotKey || plot1?.title; const p2Key = plot2?._plotKey || plot2?.title; @@ -507,15 +511,24 @@ export class FillHelper { title: title || 'Fill', plot1: p1Key, plot2: p2Key, + data: [], options: { plot1: p1Key, plot2: p2Key, - color, editable, show_last, fillgaps, display, style: 'fill', + color: resolvedColor, editable, show_last, fillgaps, display, style: 'fill', }, _plotKey: fillKey, _callsiteId: callsiteId, }; } + + // Always push per-bar color data so dynamic colors (e.g. green/red flip) work. + // The fill renderer will use per-bar colors when the data array is populated. + this.context.plots[fillKey].data.push({ + time: this.context.marketData[this.context.idx].openTime, + value: null, + options: { color: resolvedColor }, + }); } } } diff --git a/src/namespaces/Str.ts b/src/namespaces/Str.ts index 06e726f..cf73758 100644 --- a/src/namespaces/Str.ts +++ b/src/namespaces/Str.ts @@ -2,6 +2,7 @@ import { Series } from '../Series'; import { Context } from '..'; +import { PineArrayObject, PineArrayType } from './array/PineArrayObject'; export class Str { constructor(private context: Context) {} @@ -126,7 +127,7 @@ export class Str { } split(source: string, separator: string) { - return [String(source).split(separator)]; //we need to double wrap the array in an array to match the PineTS expected output structure + return new PineArrayObject(String(source).split(separator), PineArrayType.string, this.context); } substring(source: string, begin_pos: number, end_pos: number) { return String(source).substring(begin_pos, end_pos); diff --git a/src/namespaces/Timeframe.ts b/src/namespaces/Timeframe.ts index bb5dd58..a15c19c 100644 --- a/src/namespaces/Timeframe.ts +++ b/src/namespaces/Timeframe.ts @@ -1,43 +1,100 @@ import { Series } from '../Series'; const TF_UNITS = ['S', 'D', 'W', 'M']; + +/** + * Normalize a raw timeframe string to Pine Script canonical format. + * Pine canonical: minutes as plain integers ("1", "60", "240"), "D", "W", "M", or seconds as "1S". + * Common inputs: "1d", "1D", "4h", "1w", "1W", "1m" (1-minute), "1M" (1-month). + */ +const NORMALIZE_MAP: Record = { + '1m': '1', '3m': '3', '5m': '5', '15m': '15', '30m': '30', '45m': '45', + '1h': '60', '2h': '120', '3h': '180', '4h': '240', + '1d': 'D', '1w': 'W', '1M': 'M', +}; + +function normalizeTF(tf: string): string { + if (!tf) return tf; + + // Already canonical minute integers? + if (/^\d+$/.test(tf)) return tf; + + // Direct map (case-sensitive first for '1M' vs '1m') + if (NORMALIZE_MAP[tf]) return NORMALIZE_MAP[tf]; + + // Try lowercase (handles '1H', '4H', '1D', '1W' etc.) + const lower = tf.toLowerCase(); + if (NORMALIZE_MAP[lower]) return NORMALIZE_MAP[lower]; + + // Single letter: d→D, w→W, m→M, s→S + if (tf.length === 1) { + const upper = tf.toUpperCase(); + if (['D', 'W', 'M', 'S'].includes(upper)) return upper; + } + + // Uppercase last char: "2D", "3W", "12M", "30S" + const lastChar = tf.slice(-1).toUpperCase(); + if (['D', 'W', 'M', 'S'].includes(lastChar)) { + const num = parseInt(tf); + if (!isNaN(num)) return num + lastChar; + } + + return tf; +} + export class Timeframe { + private _normalized: string | null = null; + constructor(private context: any) {} + param(source: any, index: number = 0, name?: string) { return Series.from(source).get(index); } + /** Normalized canonical timeframe (cached) */ + private get normalized(): string { + if (this._normalized === null) { + this._normalized = normalizeTF(this.context.timeframe); + } + return this._normalized; + } + + /** Last character of the normalized timeframe (uppercase) */ + private get unit(): string { + return this.normalized.slice(-1).toUpperCase(); + } + //Note : current PineTS implementation does not differentiate between main_period and period because the timeframe is always taken from the main execution context. //once we implement indicator() function, the main_period can be overridden by the indicator's timeframe. public get main_period() { - return this.context.timeframe; + return this.normalized; } public get period() { - return this.context.timeframe; + return this.normalized; } public get multiplier() { - const val = parseInt(this.context.timeframe); + const val = parseInt(this.normalized); return isNaN(val) ? 1 : val; } public get isdwm() { - return ['D', 'W', 'M'].includes(this.context.timeframe.slice(-1)); + return ['D', 'W', 'M'].includes(this.unit); } public get isdaily() { - return this.context.timeframe.slice(-1) === 'D'; + return this.unit === 'D'; } public get isweekly() { - return this.context.timeframe.slice(-1) === 'W'; + return this.unit === 'W'; } public get ismonthly() { - return this.context.timeframe.slice(-1) === 'M'; + return this.unit === 'M'; } public get isseconds() { - return this.context.timeframe.slice(-1) === 'S'; + return this.unit === 'S'; } public get isminutes() { - //minutes timeframes does not have a specific unit character - return parseInt(this.context.timeframe).toString() == this.context.timeframe.trim(); + //minutes timeframes are pure integers (no unit suffix) + return /^\d+$/.test(this.normalized); } public get isintraday() { @@ -59,7 +116,7 @@ export class Timeframe { const roundedMinutes = Math.ceil(seconds / 60); return roundedMinutes; } - //wheck whole weeks first + //check whole weeks first if (seconds <= 60 * 60 * 24 * 7 * 52) { //is whole weeks ? if (seconds % (60 * 60 * 24 * 7) === 0) { @@ -74,20 +131,25 @@ export class Timeframe { return '12M'; } - public in_seconds(timeframe: string) { + public in_seconds(timeframe?: string) { + if (timeframe === undefined || timeframe === null) { + timeframe = this.normalized; + } else { + timeframe = normalizeTF(timeframe); + } + const unit = timeframe.slice(-1).toUpperCase(); const multiplier = parseInt(timeframe); - const unit = timeframe.slice(-1); if (unit === 'S') { - return multiplier; + return isNaN(multiplier) ? 1 : multiplier; } if (unit === 'D') { - return multiplier * 60 * 60 * 24; + return (isNaN(multiplier) ? 1 : multiplier) * 60 * 60 * 24; } if (unit === 'W') { - return multiplier * 60 * 60 * 24 * 7; + return (isNaN(multiplier) ? 1 : multiplier) * 60 * 60 * 24 * 7; } if (unit === 'M') { - return multiplier * 60 * 60 * 24 * 30; + return (isNaN(multiplier) ? 1 : multiplier) * 60 * 60 * 24 * 30; } // Minutes (no unit suffix or implicit minutes) if (!isNaN(multiplier)) { diff --git a/src/namespaces/Types.ts b/src/namespaces/Types.ts index acec01b..69bc87a 100644 --- a/src/namespaces/Types.ts +++ b/src/namespaces/Types.ts @@ -163,11 +163,16 @@ export enum extend { } export enum text { + align_bottom = 'bottom', + align_top = 'top', align_left = 'left', align_center = 'center', align_right = 'right', wrap_auto = 'auto', wrap_none = 'none', + format_bold = 'bold', + format_italic = 'italic', + format_none = 'none', } export enum font { @@ -222,6 +227,18 @@ export enum position { bottom_right = 'bottom_right', } +export enum scale { + left = 'left', + none = 'none', + right = 'right', +} + +export enum settlement_as_close { + inherit = 'inherit', + off = 'off', + on = 'on', +} + const types = { order, currency, @@ -243,6 +260,8 @@ const types = { dividends, splits, position, + scale, + settlement_as_close, }; export default types; diff --git a/src/namespaces/array/methods/new.ts b/src/namespaces/array/methods/new.ts index 5f8f784..7706d03 100644 --- a/src/namespaces/array/methods/new.ts +++ b/src/namespaces/array/methods/new.ts @@ -6,16 +6,17 @@ import { Context } from '../../../Context.class'; export function new_fn(context: Context) { return (size?: number, initial_value?: T): PineArrayObject => { + const safeSize = (typeof size === 'number' && size > 0 && !isNaN(size)) ? Math.floor(size) : 0; // When no initial_value is provided, create an untyped (any) array. // The generic type parameter (e.g. , ) is lost during // transpilation, so we can't infer the element type — 'any' accepts all values. // Pine Script fills with 0 when size > 0 and no initial_value is given. if (initial_value === undefined) { - const arr = size ? Array(size).fill(0) : []; + const arr = safeSize ? Array(safeSize).fill(0) : []; return new PineArrayObject(arr, PineArrayType.any, context); } return new PineArrayObject( - Array(size).fill(context.precision((initial_value as number) || 0)), + Array(safeSize).fill(context.precision((initial_value as number) || 0)), inferValueType((initial_value as any) || 0), context ); diff --git a/src/namespaces/array/methods/new_bool.ts b/src/namespaces/array/methods/new_bool.ts index 786b2a5..a78efb7 100644 --- a/src/namespaces/array/methods/new_bool.ts +++ b/src/namespaces/array/methods/new_bool.ts @@ -3,7 +3,8 @@ import { PineArrayObject, PineArrayType } from '../PineArrayObject'; export function new_bool(context: any) { - return (size: number, initial_value: boolean = false): PineArrayObject => { - return new PineArrayObject(Array(size).fill(initial_value), PineArrayType.bool, context); + return (size: number = 0, initial_value: boolean = false): PineArrayObject => { + const safeSize = (typeof size === 'number' && size > 0 && !isNaN(size)) ? Math.floor(size) : 0; + return new PineArrayObject(Array(safeSize).fill(initial_value), PineArrayType.bool, context); }; } diff --git a/src/namespaces/array/methods/new_box.ts b/src/namespaces/array/methods/new_box.ts index 35ded91..d8832b8 100644 --- a/src/namespaces/array/methods/new_box.ts +++ b/src/namespaces/array/methods/new_box.ts @@ -4,6 +4,7 @@ import { PineArrayObject, PineArrayType } from '../PineArrayObject'; export function new_box(context: any) { return (size: number = 0, initial_value: any = null): PineArrayObject => { - return new PineArrayObject(Array(size).fill(initial_value), PineArrayType.box, context); + const safeSize = (typeof size === 'number' && size > 0 && !isNaN(size)) ? Math.floor(size) : 0; + return new PineArrayObject(Array(safeSize).fill(initial_value), PineArrayType.box, context); }; } diff --git a/src/namespaces/array/methods/new_color.ts b/src/namespaces/array/methods/new_color.ts index aee33c9..e2db1c8 100644 --- a/src/namespaces/array/methods/new_color.ts +++ b/src/namespaces/array/methods/new_color.ts @@ -4,6 +4,7 @@ import { PineArrayObject, PineArrayType } from '../PineArrayObject'; export function new_color(context: any) { return (size: number = 0, initial_value: any = null): PineArrayObject => { - return new PineArrayObject(Array(size).fill(initial_value), PineArrayType.color, context); + const safeSize = (typeof size === 'number' && size > 0 && !isNaN(size)) ? Math.floor(size) : 0; + return new PineArrayObject(Array(safeSize).fill(initial_value), PineArrayType.color, context); }; } diff --git a/src/namespaces/array/methods/new_float.ts b/src/namespaces/array/methods/new_float.ts index d892300..0fb9d7f 100644 --- a/src/namespaces/array/methods/new_float.ts +++ b/src/namespaces/array/methods/new_float.ts @@ -4,7 +4,9 @@ import { PineArrayObject, PineArrayType } from '../PineArrayObject'; import { Context } from '../../../Context.class'; export function new_float(context: Context) { - return (size: number, initial_value: number = NaN): PineArrayObject => { - return new PineArrayObject(Array(size).fill(context.precision(initial_value)), PineArrayType.float, context); + return (size: number = 0, initial_value: number = NaN): PineArrayObject => { + // Guard: na (NaN) or negative size → empty array (matches TradingView behavior) + const safeSize = (typeof size === 'number' && size > 0 && !isNaN(size)) ? Math.floor(size) : 0; + return new PineArrayObject(Array(safeSize).fill(context.precision(initial_value)), PineArrayType.float, context); }; } diff --git a/src/namespaces/array/methods/new_int.ts b/src/namespaces/array/methods/new_int.ts index 37dbd73..0a6d312 100644 --- a/src/namespaces/array/methods/new_int.ts +++ b/src/namespaces/array/methods/new_int.ts @@ -4,7 +4,8 @@ import { PineArrayObject, PineArrayType } from '../PineArrayObject'; import { Context } from '../../../Context.class'; export function new_int(context: Context) { - return (size: number, initial_value: number = 0): PineArrayObject => { - return new PineArrayObject(Array(size).fill(context.precision(initial_value)), PineArrayType.int, context); + return (size: number = 0, initial_value: number = 0): PineArrayObject => { + const safeSize = (typeof size === 'number' && size > 0 && !isNaN(size)) ? Math.floor(size) : 0; + return new PineArrayObject(Array(safeSize).fill(context.precision(initial_value)), PineArrayType.int, context); }; } diff --git a/src/namespaces/array/methods/new_label.ts b/src/namespaces/array/methods/new_label.ts index 104cfe9..0452183 100644 --- a/src/namespaces/array/methods/new_label.ts +++ b/src/namespaces/array/methods/new_label.ts @@ -4,6 +4,7 @@ import { PineArrayObject, PineArrayType } from '../PineArrayObject'; export function new_label(context: any) { return (size: number = 0, initial_value: any = null): PineArrayObject => { - return new PineArrayObject(Array(size).fill(initial_value), PineArrayType.label, context); + const safeSize = (typeof size === 'number' && size > 0 && !isNaN(size)) ? Math.floor(size) : 0; + return new PineArrayObject(Array(safeSize).fill(initial_value), PineArrayType.label, context); }; } diff --git a/src/namespaces/array/methods/new_line.ts b/src/namespaces/array/methods/new_line.ts index 2ae6162..f7c7886 100644 --- a/src/namespaces/array/methods/new_line.ts +++ b/src/namespaces/array/methods/new_line.ts @@ -4,6 +4,7 @@ import { PineArrayObject, PineArrayType } from '../PineArrayObject'; export function new_line(context: any) { return (size: number = 0, initial_value: any = null): PineArrayObject => { - return new PineArrayObject(Array(size).fill(initial_value), PineArrayType.line, context); + const safeSize = (typeof size === 'number' && size > 0 && !isNaN(size)) ? Math.floor(size) : 0; + return new PineArrayObject(Array(safeSize).fill(initial_value), PineArrayType.line, context); }; } diff --git a/src/namespaces/array/methods/new_linefill.ts b/src/namespaces/array/methods/new_linefill.ts index 730add3..4e60dec 100644 --- a/src/namespaces/array/methods/new_linefill.ts +++ b/src/namespaces/array/methods/new_linefill.ts @@ -4,6 +4,7 @@ import { PineArrayObject, PineArrayType } from '../PineArrayObject'; export function new_linefill(context: any) { return (size: number = 0, initial_value: any = null): PineArrayObject => { - return new PineArrayObject(Array(size).fill(initial_value), PineArrayType.linefill, context); + const safeSize = (typeof size === 'number' && size > 0 && !isNaN(size)) ? Math.floor(size) : 0; + return new PineArrayObject(Array(safeSize).fill(initial_value), PineArrayType.linefill, context); }; } diff --git a/src/namespaces/array/methods/new_string.ts b/src/namespaces/array/methods/new_string.ts index 3ae6d48..a59001b 100644 --- a/src/namespaces/array/methods/new_string.ts +++ b/src/namespaces/array/methods/new_string.ts @@ -3,7 +3,8 @@ import { PineArrayObject, PineArrayType } from '../PineArrayObject'; export function new_string(context: any) { - return (size: number, initial_value: string = ''): PineArrayObject => { - return new PineArrayObject(Array(size).fill(initial_value), PineArrayType.string, context); + return (size: number = 0, initial_value: string = ''): PineArrayObject => { + const safeSize = (typeof size === 'number' && size > 0 && !isNaN(size)) ? Math.floor(size) : 0; + return new PineArrayObject(Array(safeSize).fill(initial_value), PineArrayType.string, context); }; } diff --git a/src/namespaces/array/methods/new_table.ts b/src/namespaces/array/methods/new_table.ts index de5e5d0..24aac3f 100644 --- a/src/namespaces/array/methods/new_table.ts +++ b/src/namespaces/array/methods/new_table.ts @@ -4,6 +4,7 @@ import { PineArrayObject, PineArrayType } from '../PineArrayObject'; export function new_table(context: any) { return (size: number = 0, initial_value: any = null): PineArrayObject => { - return new PineArrayObject(Array(size).fill(initial_value), PineArrayType.table, context); + const safeSize = (typeof size === 'number' && size > 0 && !isNaN(size)) ? Math.floor(size) : 0; + return new PineArrayObject(Array(safeSize).fill(initial_value), PineArrayType.table, context); }; } diff --git a/src/namespaces/array/utils.ts b/src/namespaces/array/utils.ts index 621d1a2..7a0066f 100644 --- a/src/namespaces/array/utils.ts +++ b/src/namespaces/array/utils.ts @@ -54,6 +54,8 @@ export function isArrayOfType(array: any[], type: PineArrayType) { } export function isValueOfType(value: any, type: PineArrayType) { + // na (NaN) is compatible with all types in Pine Script + if (typeof value === 'number' && isNaN(value)) return true; // Untyped arrays (e.g. array.new()) accept any value if (type === PineArrayType.any) return true; switch (type) { diff --git a/src/namespaces/box/BoxHelper.ts b/src/namespaces/box/BoxHelper.ts index 1c460b9..ab6b02a 100644 --- a/src/namespaces/box/BoxHelper.ts +++ b/src/namespaces/box/BoxHelper.ts @@ -16,9 +16,9 @@ const BOX_NEW_SIGNATURES = [ const BOX_NEW_ARGS_TYPES: Record = { left: 'number', top: 'number', right: 'number', bottom: 'number', top_left: 'point', bottom_right: 'point', - border_color: 'string', border_width: 'number', border_style: 'string', - extend: 'string', xloc: 'string', bgcolor: 'string', - text: 'string', text_size: 'string', text_color: 'string', + border_color: 'color', border_width: 'number', border_style: 'string', + extend: 'string', xloc: 'string', bgcolor: 'color', + text: 'string', text_size: 'string', text_color: 'color', text_halign: 'string', text_valign: 'string', text_wrap: 'string', text_font_family: 'string', force_overlay: 'boolean', }; diff --git a/src/namespaces/chart/ChartHelper.ts b/src/namespaces/chart/ChartHelper.ts index dcc3641..31af170 100644 --- a/src/namespaces/chart/ChartHelper.ts +++ b/src/namespaces/chart/ChartHelper.ts @@ -45,7 +45,7 @@ export class ChartHelper { } fg_color(): string { - return '#ffffff'; + return '#d1d4dc'; } is_standard(): boolean { diff --git a/src/namespaces/color/PineColor.ts b/src/namespaces/color/PineColor.ts new file mode 100644 index 0000000..d50d0fb --- /dev/null +++ b/src/namespaces/color/PineColor.ts @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { Series } from '../../Series'; + +/** + * Parse any color string (#hex, #hexAA, rgb(), rgba()) into [r, g, b, a] with a in 0..1. + * Returns null if unparsable. + */ +function parseColorToRGBA(color: string): [number, number, number, number] | null { + if (!color || typeof color !== 'string') return null; + + // #RRGGBB or #RRGGBBAA + if (color.startsWith('#')) { + const hex = color.slice(1); + if (hex.length === 6) { + return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16), 1]; + } + if (hex.length === 8) { + return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16), parseInt(hex.slice(6, 8), 16) / 255]; + } + return null; + } + + // rgba(r, g, b, a) or rgb(r, g, b) + const rgbaMatch = color.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)/); + if (rgbaMatch) { + return [parseInt(rgbaMatch[1]), parseInt(rgbaMatch[2]), parseInt(rgbaMatch[3]), rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1]; + } + + return null; +} + +/** + * Convert [r, g, b, a] back to a hex string. If a < 1, include the alpha byte. + */ +function rgbaToHex(r: number, g: number, b: number, a: number): string { + const rr = Math.round(Math.max(0, Math.min(255, r))) + .toString(16) + .padStart(2, '0'); + const gg = Math.round(Math.max(0, Math.min(255, g))) + .toString(16) + .padStart(2, '0'); + const bb = Math.round(Math.max(0, Math.min(255, b))) + .toString(16) + .padStart(2, '0'); + if (a >= 1) return `#${rr}${gg}${bb}`.toUpperCase(); + const aa = Math.round(Math.max(0, Math.min(255, a * 255))) + .toString(16) + .padStart(2, '0'); + return `#${rr}${gg}${bb}${aa}`.toUpperCase(); +} + +//prettier-ignore +const COLOR_CONSTANTS = { + aqua: '#00BCD4', + black: '#363A45', + blue: '#2196F3', + fuchsia: '#E040FB', + gray: '#787B86', + green: '#4CAF50', + lime: '#00E676', + maroon: '#880E4F', + navy: '#311B92', + olive: '#808000', + orange: '#FF9800', + purple: '#9C27B0', + red: '#F23645', + silver: '#B2B5BE', + teal: '#089981', + white: '#FFFFFF', + yellow: '#FDD835', +} as const; + +/** + * Resolve a color argument: unwrap Series/functions to a raw value. + */ +function resolveColor(color: any): any { + if (typeof color === 'function') color = color(); + if (color && typeof color === 'object' && Array.isArray(color.data) && typeof color.get === 'function') { + color = color.get(0); + } + return color; +} + +/** + * PineColor implements the Pine Script `color` namespace. + * + * Supports: + * - color(na) → type-cast (via any()) + * - color.new(color, alpha) → apply transparency + * - color.rgb(r, g, b, a?) → create from components + * - color.from_gradient(...) → interpolate between two colors + * - color.r/g/b/t(color) → extract individual components + * - color.red, color.blue, ... → named constants + */ +export class PineColor { + constructor(private context: any) {} + + // ── Type-cast: color(na) → color.any(na) ────────────────────────── + any(value: any) { + const resolved = Series.from(value).get(0); + // NaN means na (Pine Script's "no value") → return null for transparent + if (typeof resolved === 'number' && isNaN(resolved)) return null; + return resolved; + } + + // ── Series unwrapping for param() ───────────────────────────────── + param(source: any, index: number = 0) { + return Series.from(source).get(index); + } + + // ── color.new(color, alpha?) ────────────────────────────────────── + new(color: any, a?: number) { + color = resolveColor(color); + // If not a string (e.g. NaN for na), return as-is + if (!color || typeof color !== 'string') return color; + + // Handle hexadecimal colors + if (color.startsWith('#')) { + const hex = color.slice(1); + // Strip existing alpha if present (#RRGGBBAA → #RRGGBB) before appending new alpha + const hexRgb = hex.length === 8 ? hex.slice(0, 6) : hex; + return a != null + ? `#${hexRgb}${Math.round((255 / 100) * (100 - a)) + .toString(16) + .padStart(2, '0') + .toUpperCase()}` + : `#${hex}`; + } else { + const hex = COLOR_CONSTANTS[color]; + if (hex) { + return a != null + ? `#${hex.slice(1)}${Math.round((255 / 100) * (100 - a)) + .toString(16) + .padStart(2, '0') + .toUpperCase()}` + : hex; + } + + // Handle rgb(r,g,b) and rgba(r,g,b,a) strings — extract components + // to avoid invalid nested formats like "rgba(rgb(207,23,23), 0.3)" + const rgbMatch = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/); + if (rgbMatch) { + const r = rgbMatch[1]; + const g = rgbMatch[2]; + const b = rgbMatch[3]; + if (a != null) { + // Convert to #RRGGBBAA hex for consistency with hex path + const rh = parseInt(r).toString(16).padStart(2, '0'); + const gh = parseInt(g).toString(16).padStart(2, '0'); + const bh = parseInt(b).toString(16).padStart(2, '0'); + const ah = Math.round((255 / 100) * (100 - a)).toString(16).padStart(2, '0').toUpperCase(); + return `#${rh}${gh}${bh}${ah}`; + } + return color; // no alpha change, return as-is + } + + // Fallback for unknown format + return a != null + ? `rgba(${color}, ${(100 - a) / 100})` + : color; + } + } + + // ── color.rgb(r, g, b, a?) ──────────────────────────────────────── + rgb(r: number, g: number, b: number, a?: number) { + return a != null ? `rgba(${r}, ${g}, ${b}, ${(100 - a) / 100})` : `rgb(${r}, ${g}, ${b})`; + } + + // ── color.from_gradient(value, bottom_value, top_value, bottom_color, top_color) ── + from_gradient(value: any, bottom_value: any, top_value: any, bottom_color: any, top_color: any): any { + // Resolve Series/functions for all args + value = resolveColor(value); + bottom_value = resolveColor(bottom_value); + top_value = resolveColor(top_value); + bottom_color = resolveColor(bottom_color); + top_color = resolveColor(top_color); + + // If any numeric arg is na (NaN/null/undefined), return na + if (value == null || (typeof value === 'number' && isNaN(value))) return undefined; + if (bottom_value == null || (typeof bottom_value === 'number' && isNaN(bottom_value))) return undefined; + if (top_value == null || (typeof top_value === 'number' && isNaN(top_value))) return undefined; + // If either color is na, return na + if (bottom_color == null || (typeof bottom_color === 'number' && isNaN(bottom_color))) return undefined; + if (top_color == null || (typeof top_color === 'number' && isNaN(top_color))) return undefined; + + // Clamp position between 0 and 1 + let t = 0; + if (top_value !== bottom_value) { + t = (value - bottom_value) / (top_value - bottom_value); + } + t = Math.max(0, Math.min(1, t)); + + // Parse both colors to RGBA + const bc = parseColorToRGBA(typeof bottom_color === 'string' ? bottom_color : '#000000') || [0, 0, 0, 1]; + const tc = parseColorToRGBA(typeof top_color === 'string' ? top_color : '#FFFFFF') || [255, 255, 255, 1]; + + // Linear interpolation + const r = bc[0] + (tc[0] - bc[0]) * t; + const g = bc[1] + (tc[1] - bc[1]) * t; + const b = bc[2] + (tc[2] - bc[2]) * t; + const a = bc[3] + (tc[3] - bc[3]) * t; + + return rgbaToHex(r, g, b, a); + } + + // ── Component extraction ────────────────────────────────────────── + + /** Extract red component (0-255) from a color string. Returns na if unparsable. */ + r(color: any): number { + color = resolveColor(color); + if (!color || typeof color !== 'string') return NaN; + const rgba = parseColorToRGBA(color); + return rgba ? rgba[0] : NaN; + } + + /** Extract green component (0-255) from a color string. Returns na if unparsable. */ + g(color: any): number { + color = resolveColor(color); + if (!color || typeof color !== 'string') return NaN; + const rgba = parseColorToRGBA(color); + return rgba ? rgba[1] : NaN; + } + + /** Extract blue component (0-255) from a color string. Returns na if unparsable. */ + b(color: any): number { + color = resolveColor(color); + if (!color || typeof color !== 'string') return NaN; + const rgba = parseColorToRGBA(color); + return rgba ? rgba[2] : NaN; + } + + /** Extract transparency (0-100, Pine scale) from a color string. Returns na if unparsable. */ + t(color: any): number { + color = resolveColor(color); + if (!color || typeof color !== 'string') return NaN; + const rgba = parseColorToRGBA(color); + return rgba ? Math.round(100 - rgba[3] * 100) : NaN; + } + + // ── Named color constants ───────────────────────────────────────── + // These are methods (not getters) because KNOWN_NAMESPACES transforms + // `color.white` → `color.white()` in the transpiler. They need to be + // callable functions, not static values. + aqua() { + return COLOR_CONSTANTS.aqua; + } + black() { + return COLOR_CONSTANTS.black; + } + blue() { + return COLOR_CONSTANTS.blue; + } + fuchsia() { + return COLOR_CONSTANTS.fuchsia; + } + gray() { + return COLOR_CONSTANTS.gray; + } + green() { + return COLOR_CONSTANTS.green; + } + lime() { + return COLOR_CONSTANTS.lime; + } + maroon() { + return COLOR_CONSTANTS.maroon; + } + navy() { + return COLOR_CONSTANTS.navy; + } + olive() { + return COLOR_CONSTANTS.olive; + } + orange() { + return COLOR_CONSTANTS.orange; + } + purple() { + return COLOR_CONSTANTS.purple; + } + red() { + return COLOR_CONSTANTS.red; + } + silver() { + return COLOR_CONSTANTS.silver; + } + teal() { + return COLOR_CONSTANTS.teal; + } + white() { + return COLOR_CONSTANTS.white; + } + yellow() { + return COLOR_CONSTANTS.yellow; + } +} diff --git a/src/namespaces/label/LabelHelper.ts b/src/namespaces/label/LabelHelper.ts index a36df1d..09c2ae4 100644 --- a/src/namespaces/label/LabelHelper.ts +++ b/src/namespaces/label/LabelHelper.ts @@ -17,7 +17,7 @@ const LABEL_NEW_SIGNATURES = [ //prettier-ignore const LABEL_NEW_ARGS_TYPES = { x: 'number', y: 'number', text: 'string', xloc: 'string', yloc: 'string', - color: 'string', style: 'string', textcolor: 'string', size: 'string', + color: 'color', style: 'string', textcolor: 'color', size: 'string', textalign: 'string', tooltip: 'string', text_font_family: 'string', force_overlay: 'boolean', point: 'point', }; @@ -64,16 +64,21 @@ export class LabelHelper { */ private _resolve(val: any): any { if (val === null || val === undefined) return val; - // NAHelper (na) → resolve to NaN - if (val instanceof NAHelper) return NaN; + // NAHelper (na) → resolve to null (Pine Script na) + if (val instanceof NAHelper) return null; // Resolve Series-like objects (has data array and get method) if (typeof val === 'object' && Array.isArray(val.data) && typeof val.get === 'function') { - return val.get(0); + const resolved = val.get(0); + // NaN from Series (e.g. color(na) → Series.from(NaN).get(0)) means na + if (typeof resolved === 'number' && isNaN(resolved)) return null; + return resolved; } // Resolve bound functions (like chart.bg_color, chart.fg_color) if (typeof val === 'function') { return val(); } + // NaN scalar (e.g. color(na) resolved to NaN) means na + if (typeof val === 'number' && isNaN(val)) return null; return val; } diff --git a/src/namespaces/line/LineHelper.ts b/src/namespaces/line/LineHelper.ts index 32de4af..50767fc 100644 --- a/src/namespaces/line/LineHelper.ts +++ b/src/namespaces/line/LineHelper.ts @@ -15,7 +15,7 @@ const LINE_NEW_SIGNATURES = [ //prettier-ignore const LINE_NEW_ARGS_TYPES = { x1: 'number', y1: 'number', x2: 'number', y2: 'number', - xloc: 'string', extend: 'string', color: 'string', style: 'string', + xloc: 'string', extend: 'string', color: 'color', style: 'string', width: 'number', force_overlay: 'boolean', first_point: 'point', second_point: 'point', }; diff --git a/src/namespaces/linefill/LinefillHelper.ts b/src/namespaces/linefill/LinefillHelper.ts index 12bad43..221894b 100644 --- a/src/namespaces/linefill/LinefillHelper.ts +++ b/src/namespaces/linefill/LinefillHelper.ts @@ -54,8 +54,13 @@ export class LinefillHelper { // linefill.new(line1, line2, color) → series linefill new(line1: LineObject, line2: LineObject, color: any): LinefillObject { + // Resolve thunks: in `var` UDT declarations, line.new() calls are hoisted + // as thunks (functions). Resolve them here so LinefillObject stores actual + // LineObjects, not unresolved functions. + const resolvedLine1 = this._resolve(line1) as LineObject; + const resolvedLine2 = this._resolve(line2) as LineObject; const resolvedColor = this._resolve(color) || ''; - const lf = new LinefillObject(line1, line2, resolvedColor); + const lf = new LinefillObject(resolvedLine1, resolvedLine2, resolvedColor); lf._createdAtBar = this.context.idx; this._linefills.push(lf); this._syncToPlot(); diff --git a/src/namespaces/linefill/LinefillObject.ts b/src/namespaces/linefill/LinefillObject.ts index 91be159..34468e0 100644 --- a/src/namespaces/linefill/LinefillObject.ts +++ b/src/namespaces/linefill/LinefillObject.ts @@ -25,6 +25,30 @@ export class LinefillObject { this._deleted = false; } + // Instance methods — mirror the static methods on LinefillHelper + // so that instance-method syntax works when linefill is a UDT field. + + get_line1(): LineObject { + return this.line1; + } + + get_line2(): LineObject { + return this.line2; + } + + set_color(color: any): void { + if (!this._deleted) { + // Resolve Series/thunks — instance methods receive raw transpiler + // values that may still be wrapped (unlike LinefillHelper.set_color + // which calls _resolve()). + if (typeof color === 'function') color = color(); + if (color && typeof color === 'object' && Array.isArray(color.data) && typeof color.get === 'function') { + color = color.get(0); + } + this.color = color || ''; + } + } + delete(): void { this._deleted = true; } diff --git a/src/namespaces/math/math.index.ts b/src/namespaces/math/math.index.ts index 9df8a76..31a84ee 100644 --- a/src/namespaces/math/math.index.ts +++ b/src/namespaces/math/math.index.ts @@ -33,6 +33,7 @@ import { tan } from './methods/tan'; import { todegrees } from './methods/todegrees'; import { toradians } from './methods/toradians'; import { __eq } from './methods/__eq'; +import { __neq } from './methods/__neq'; const methods = { abs, @@ -65,7 +66,8 @@ const methods = { tan, todegrees, toradians, - __eq + __eq, + __neq }; export class PineMath { @@ -101,6 +103,7 @@ export class PineMath { todegrees: ReturnType; toradians: ReturnType; __eq: ReturnType; + __neq: ReturnType; constructor(private context: any) { // Install methods diff --git a/src/namespaces/math/methods/__neq.ts b/src/namespaces/math/methods/__neq.ts new file mode 100644 index 0000000..0d669e9 --- /dev/null +++ b/src/namespaces/math/methods/__neq.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { Series } from '../../../Series'; + +/** + * Pine Script na-aware inequality comparison. + * + * In Pine Script, any comparison involving `na` returns `false`: + * na != na → false + * 1 != na → false + * na != 1 → false + * + * This cannot be implemented as `!__eq(a, b)` because __eq(na, na) returns + * false, and !false = true — which is wrong. Both == and != must independently + * return false when either operand is na. + */ +export function __neq(context: any) { + return (a: any, b: any) => { + // Unwrap Series + const valA = Series.from(a).get(0); + const valB = Series.from(b).get(0); + + if (typeof valA === 'number' && typeof valB === 'number') { + // Pine Script: any comparison with na (NaN) returns false + if (isNaN(valA) || isNaN(valB)) return false; + + // Use epsilon comparison consistent with __eq + return Math.abs(valA - valB) >= 1e-8; + } + + return valA !== valB; + }; +} diff --git a/src/namespaces/matrix/matrix.index.ts b/src/namespaces/matrix/matrix.index.ts index ae1e04f..f358405 100644 --- a/src/namespaces/matrix/matrix.index.ts +++ b/src/namespaces/matrix/matrix.index.ts @@ -64,6 +64,18 @@ export class PineMatrix { this.swap_rows = (id: PineMatrixObject, ...args: any[]) => id.swap_rows(...args); this.trace = (id: PineMatrixObject, ...args: any[]) => id.trace(...args); this.transpose = (id: PineMatrixObject, ...args: any[]) => id.transpose(...args); + // Type-specific aliases — used internally by PineTS to handle strong types. + // The transpiler rewrites matrix.new(...) → matrix.new_float(...) + this.new_float = new_fn(context); + this.new_int = new_fn(context); + this.new_string = new_fn(context); + this.new_bool = new_fn(context); + this.new_color = new_fn(context); + this.new_line = new_fn(context); + this.new_label = new_fn(context); + this.new_box = new_fn(context); + this.new_linefill = new_fn(context); + this.new_table = new_fn(context); } } diff --git a/src/namespaces/polyline/PolylineHelper.ts b/src/namespaces/polyline/PolylineHelper.ts index b9381d0..4e93fe6 100644 --- a/src/namespaces/polyline/PolylineHelper.ts +++ b/src/namespaces/polyline/PolylineHelper.ts @@ -53,6 +53,16 @@ export class PolylineHelper { return val; } + /** + * Resolve a color value, preserving NaN (na = no color) instead of + * letting it fall through to a default via the || operator. + */ + private _resolveColor(val: any, fallback: string): any { + const resolved = this._resolve(val); + if (typeof resolved === 'number' && isNaN(resolved)) return NaN; + return resolved || fallback; + } + /** * Extract raw ChartPointObject array from a PineArrayObject, Series, or plain array. */ @@ -81,10 +91,6 @@ export class PolylineHelper { let line_width: any = 1; let force_overlay: any = false; - const isOptionsObj = (v: any) => v && typeof v === 'object' && !Array.isArray(v) - && ('points' in v || 'curved' in v || 'closed' in v || 'line_color' in v - || 'fill_color' in v || 'line_style' in v || 'line_width' in v); - const applyOpts = (opts: any) => { curved = opts.curved ?? curved; closed = opts.closed ?? closed; @@ -96,25 +102,40 @@ export class PolylineHelper { force_overlay = opts.force_overlay ?? force_overlay; }; - if (args.length === 1 && isOptionsObj(args[0]) && 'points' in args[0]) { + // Detect trailing named-options object. + // The transpiler places named arguments as a plain object at the end: + // polyline.new(pts, false, true, { line_color: '#00E676' }) + // Must distinguish from Series, ChartPointObject, PineArrayObject, etc. + const lastArg = args.length >= 1 ? args[args.length - 1] : null; + const isTrailingOpts = args.length >= 2 + && lastArg && typeof lastArg === 'object' + && !Array.isArray(lastArg) + && !(lastArg instanceof Series) + && !(lastArg instanceof ChartPointObject); + + if (args.length === 1 && lastArg && typeof lastArg === 'object' + && !Array.isArray(lastArg) && 'points' in lastArg) { // Single options object with all named params including 'points' - points = args[0].points; - applyOpts(args[0]); - } else if (args.length === 2 && args[1] && typeof args[1] === 'object' && !Array.isArray(args[1]) && isOptionsObj(args[1])) { - // Points as first arg, options object as second - points = args[0]; - applyOpts(args[1]); + points = lastArg.points; + applyOpts(lastArg); } else { - // Positional arguments - points = args[0]; - curved = args[1] ?? curved; - closed = args[2] ?? closed; - xloc = args[3] ?? xloc; - line_color = args[4] ?? line_color; - fill_color = args[5] ?? fill_color; - line_style = args[6] ?? line_style; - line_width = args[7] ?? line_width; - force_overlay = args[8] ?? force_overlay; + // Split into positional args + optional trailing options object + const positional = isTrailingOpts ? args.slice(0, -1) : args; + + points = positional[0]; + curved = positional[1] ?? curved; + closed = positional[2] ?? closed; + xloc = positional[3] ?? xloc; + line_color = positional[4] ?? line_color; + fill_color = positional[5] ?? fill_color; + line_style = positional[6] ?? line_style; + line_width = positional[7] ?? line_width; + force_overlay = positional[8] ?? force_overlay; + + // Named opts override positional args + if (isTrailingOpts) { + applyOpts(lastArg); + } } const resolvedPoints = this._extractPoints(points); @@ -123,8 +144,8 @@ export class PolylineHelper { this._resolve(curved) ?? false, this._resolve(closed) ?? false, this._resolve(xloc) || 'bi', - this._resolve(line_color) || '#2962ff', - this._resolve(fill_color) || '', + this._resolveColor(line_color, '#2962ff'), + this._resolveColor(fill_color, ''), this._resolve(line_style) || 'style_solid', this._resolve(line_width) || 1, this._resolve(force_overlay) ?? false, diff --git a/src/namespaces/request/methods/param.ts b/src/namespaces/request/methods/param.ts index bb238b6..b630663 100644 --- a/src/namespaces/request/methods/param.ts +++ b/src/namespaces/request/methods/param.ts @@ -13,27 +13,20 @@ export function param(context: any) { } else if (Array.isArray(source)) { // Check if this is a tuple expression vs a time-series array // - // For request.security, tuples are always passed as arrays of expressions. - // Heuristic: If the array contains Series objects OR scalar values (not nested arrays), - // and it's NOT a single-element array, treat it as a tuple. + // For request.security/security_lower_tf, tuples are always passed as arrays of expressions. + // A tuple can contain Series objects, scalars, or a mix (e.g., [open, close, wVolSrc()]). // - // A time-series array would be a forward chronological array of values, - // which would have nested structure like [[a,b], [a,b], ...] OR simple values [1,2,3...] - // but NOT a mix of Series objects at the top level. + // Detection: If any element is a Series, it's a tuple (time-series arrays don't contain + // Series at the top level). If all elements are scalars (no nested arrays), it's also + // a tuple of literal values. Only arrays with nested arrays are time-series data. - const hasOnlySeries = source.every((elem) => elem instanceof Series); + const hasAnySeries = source.some((elem) => elem instanceof Series); const hasOnlyScalars = source.every((elem) => !(elem instanceof Series) && !Array.isArray(elem)); - const isTuple = (hasOnlySeries || hasOnlyScalars) && source.length >= 1; + const isTuple = (hasAnySeries || hasOnlyScalars) && source.length >= 1; if (isTuple) { - // Preserve the tuple, but extract values from Series - if (hasOnlySeries) { - // Extract current value from each Series - val = source.map((s: Series) => s.get(0)); - } else { - // Already scalar values - val = source; - } + // Extract current value from each element (Series → .get(0), scalars pass through) + val = source.map((elem: any) => elem instanceof Series ? elem.get(0) : elem); } else { // Time-series array - extract value at index val = Series.from(source).get(index || 0); diff --git a/src/namespaces/request/methods/security.ts b/src/namespaces/request/methods/security.ts index 41a7f69..628a6ba 100644 --- a/src/namespaces/request/methods/security.ts +++ b/src/namespaces/request/methods/security.ts @@ -6,6 +6,24 @@ import { TIMEFRAMES, normalizeTimeframe } from '../utils/TIMEFRAMES'; import { findSecContextIdx } from '../utils/findSecContextIdx'; import { findLTFContextIdx } from '../utils/findLTFContextIdx'; +/** + * Resolve raw expression values that may contain helper objects + * (TimeComponentHelper, TimeHelper, NAHelper, Series, etc.) + * into their primitive values. This is needed for same-timeframe + * and secondary-context shortcuts where the expression isn't + * re-evaluated through a full secondary run. + */ +function resolveExprValue(v: any): any { + if (v == null || typeof v !== 'object') return v; + // TimeComponentHelper, TimeHelper, NAHelper — expose __value + if ('__value' in v) return v.__value; + // Series — get current value + if (v instanceof Series) return v.get(0); + // Tuple array — resolve each element + if (Array.isArray(v)) return v.map(resolveExprValue); + return v; +} + export function security(context: any) { return async ( symbol: any, @@ -19,9 +37,13 @@ export function security(context: any) { ) => { // Strip exchange prefix (e.g. "BINANCE:BTCUSDC" → "BTCUSDC") so the // provider receives a clean ticker when creating a secondary context. - const rawSymbol = symbol[0]; - const _symbol = typeof rawSymbol === 'string' && rawSymbol.includes(':') ? rawSymbol.split(':')[1] : rawSymbol; - const _timeframe = timeframe[0]; + const rawSymbol = symbol[0] instanceof Series ? (symbol[0] as Series).get(0) : symbol[0]; + // Empty string "" means "use chart's symbol" (Pine Script spec) + const resolvedSymbol = rawSymbol === '' ? context.tickerId : rawSymbol; + const _symbol = typeof resolvedSymbol === 'string' && resolvedSymbol.includes(':') ? resolvedSymbol.split(':')[1] : resolvedSymbol; + const rawTimeframe = timeframe[0] instanceof Series ? (timeframe[0] as Series).get(0) : timeframe[0]; + // Empty string "" means "use chart's timeframe" (Pine Script spec) + const _timeframe = rawTimeframe === '' ? context.timeframe : (typeof rawTimeframe === 'string' ? rawTimeframe : String(rawTimeframe ?? '')); const _expression = expression[0]; const _expression_name = expression[1]; const _gapsRaw = Array.isArray(gaps) ? gaps[0] : gaps; @@ -35,7 +57,8 @@ export function security(context: any) { // If this is a secondary context (created by another request.security), // just return the expression value directly without creating another context if (context.isSecondaryContext) { - return Array.isArray(_expression) ? [_expression] : _expression; + const resolved = resolveExprValue(_expression); + return Array.isArray(resolved) ? [resolved] : resolved; } const ctxTimeframeIdx = TIMEFRAMES.indexOf(normalizeTimeframe(context.timeframe)); @@ -46,9 +69,11 @@ export function security(context: any) { } if (ctxTimeframeIdx === reqTimeframeIdx) { - // Wrap tuples in 2D array to match $.precision() convention - // (same wrapping as HTF/LTF return paths below) - return Array.isArray(_expression) ? [_expression] : _expression; + // Same-timeframe shortcut: resolve any helper objects (TimeComponentHelper, + // NAHelper, Series, etc.) in the expression that haven't been extracted + // to their primitive values yet. + const resolved = resolveExprValue(_expression); + return Array.isArray(resolved) ? [resolved] : resolved; } const isLTF = ctxTimeframeIdx > reqTimeframeIdx; diff --git a/src/namespaces/request/methods/security_lower_tf.ts b/src/namespaces/request/methods/security_lower_tf.ts index cc37971..4c10742 100644 --- a/src/namespaces/request/methods/security_lower_tf.ts +++ b/src/namespaces/request/methods/security_lower_tf.ts @@ -3,6 +3,17 @@ import { PineTS } from '../../../PineTS.class'; import { Series } from '../../../Series'; import { TIMEFRAMES, normalizeTimeframe } from '../utils/TIMEFRAMES'; +import { PineArrayObject, PineArrayType } from '../../array/PineArrayObject'; + +/** + * Detect the PineArrayType from a runtime value. + */ +function detectArrayType(value: any): PineArrayType { + if (typeof value === 'number') return PineArrayType.float; + if (typeof value === 'boolean') return PineArrayType.bool; + if (typeof value === 'string') return PineArrayType.string; + return PineArrayType.any; +} /** * Requests the results of an expression from a specified symbol on a timeframe lower than or equal to the chart's timeframe. @@ -22,8 +33,13 @@ export function security_lower_tf(context: any) { ignore_invalid_timeframe: boolean | any[] = false, calc_bars_count: number | any[] = 0 ) => { - const _symbol = symbol[0]; - const _timeframe = timeframe[0]; + const rawSymbol = symbol[0] instanceof Series ? (symbol[0] as Series).get(0) : symbol[0]; + // Empty string "" means "use chart's symbol" (Pine Script spec) + const resolvedSymbol = rawSymbol === '' ? context.tickerId : rawSymbol; + const _symbol = typeof resolvedSymbol === 'string' && resolvedSymbol.includes(':') ? resolvedSymbol.split(':')[1] : resolvedSymbol; + const rawTimeframe = timeframe[0] instanceof Series ? (timeframe[0] as Series).get(0) : timeframe[0]; + // Empty string "" means "use chart's timeframe" (Pine Script spec) + const _timeframe = rawTimeframe === '' ? context.timeframe : (typeof rawTimeframe === 'string' ? rawTimeframe : String(rawTimeframe ?? '')); const _expression = expression[0]; const _expression_name = expression[1]; const _ignore_invalid_symbol = Array.isArray(ignore_invalid_symbol) ? ignore_invalid_symbol[0] : ignore_invalid_symbol; @@ -31,8 +47,16 @@ export function security_lower_tf(context: any) { // const _calc_bars_count = Array.isArray(calc_bars_count) ? calc_bars_count[0] : calc_bars_count; // CRITICAL: Prevent infinite recursion in secondary contexts + // Still wrap in PineArrayObject so array.size() etc. work in the secondary script if (context.isSecondaryContext) { - return Array.isArray(_expression) ? [_expression] : _expression; + if (Array.isArray(_expression)) { + const arrays = _expression.map((v: any) => + new PineArrayObject([v], detectArrayType(v), context) + ); + return [arrays]; + } else { + return new PineArrayObject([_expression], detectArrayType(_expression), context); + } } const ctxTimeframeIdx = TIMEFRAMES.indexOf(normalizeTimeframe(context.timeframe)); @@ -49,7 +73,15 @@ export function security_lower_tf(context: any) { } if (reqTimeframeIdx === ctxTimeframeIdx) { - return [[_expression]]; + if (Array.isArray(_expression)) { + // Tuple: each element becomes a 1-element PineArrayObject + const arrays = _expression.map((v: any) => + new PineArrayObject([v], detectArrayType(v), context) + ); + return [arrays]; // 2D for tuple destructuring + } else { + return new PineArrayObject([_expression], detectArrayType(_expression), context); + } } const cacheKey = `${_symbol}_${_timeframe}_${_expression_name}_lower`; @@ -96,7 +128,15 @@ export function security_lower_tf(context: any) { const secValues = secContext.params[_expression_name]; // If expression was not evaluated in secondary context (e.g. conditional execution), return empty array - if (!secValues) return []; + if (!secValues) { + if (Array.isArray(_expression)) { + const arrays = _expression.map(() => + new PineArrayObject([], PineArrayType.float, context) + ); + return [arrays]; + } + return new PineArrayObject([], PineArrayType.float, context); + } const result: any[] = []; @@ -106,20 +146,37 @@ export function security_lower_tf(context: any) { // Optimization: skip bars before our window if (sClose <= myOpenTime) continue; - + // Stop if we passed our window if (sOpen >= myCloseTime) break; // Overlap check: The LTF bar must overlap with the HTF bar interval [myOpenTime, myCloseTime) // Pine Script security_lower_tf returns all LTF bars that "belong" to the HTF bar. // This typically means any LTF bar whose time is >= HTF openTime and < HTF closeTime. - + // If sOpen >= myOpenTime and sOpen < myCloseTime, it belongs to this bar. if (sOpen >= myOpenTime && sOpen < myCloseTime) { result.push(secValues[i]); } } - - return [result]; + + // Detect if expression is a tuple (each bar value is an array) + const isTuple = result.length > 0 && Array.isArray(result[0]); + + if (isTuple) { + // Transpose: per-bar tuples [[o1,c1],[o2,c2],...] → per-element arrays [PAO([o1,o2,...]), PAO([c1,c2,...])] + const numElements = result[0].length; + const transposed = []; + for (let e = 0; e < numElements; e++) { + const columnValues = result.map(barTuple => barTuple[e]); + const type = columnValues.length > 0 ? detectArrayType(columnValues[0]) : PineArrayType.float; + transposed.push(new PineArrayObject(columnValues, type, context)); + } + return [transposed]; // 2D for tuple destructuring + } else { + // Scalar: single array of values wrapped in PineArrayObject + const type = result.length > 0 ? detectArrayType(result[0]) : PineArrayType.float; + return new PineArrayObject(result, type, context); + } }; } diff --git a/src/namespaces/table/TableHelper.ts b/src/namespaces/table/TableHelper.ts index 79fac11..7797877 100644 --- a/src/namespaces/table/TableHelper.ts +++ b/src/namespaces/table/TableHelper.ts @@ -34,6 +34,9 @@ export class TableHelper { private _resolve(val: any): any { if (val === null || val === undefined) return val; + // NAHelper object — Pine Script's `na` used as a value resolves to NaN. + // This happens when na is a default parameter (e.g., `color background = na`). + if (typeof val === 'object' && '__value' in val) return val.__value; if (typeof val === 'object' && Array.isArray(val.data) && typeof val.get === 'function') { return val.get(0); } @@ -134,7 +137,9 @@ export class TableHelper { const hasOpts = lastArg && typeof lastArg === 'object' && !Array.isArray(lastArg) && ('text' in lastArg || 'bgcolor' in lastArg || 'text_color' in lastArg || 'text_size' in lastArg || 'text_halign' in lastArg || 'tooltip' in lastArg - || 'column' in lastArg || 'row' in lastArg); + || 'column' in lastArg || 'row' in lastArg + || 'height' in lastArg || 'width' in lastArg + || 'text_valign' in lastArg || 'text_font_family' in lastArg); if (hasOpts) { const opts = lastArg; diff --git a/src/namespaces/table/TableObject.ts b/src/namespaces/table/TableObject.ts index a9766ca..34080bc 100644 --- a/src/namespaces/table/TableObject.ts +++ b/src/namespaces/table/TableObject.ts @@ -87,10 +87,16 @@ export class TableObject { const existing = this.cells[row][column]; if (existing && existing._merged && existing._merge_parent) { - // Redirect to merge parent + // Redirect to merge parent (guard against self-reference to prevent infinite recursion) const [pc, pr] = existing._merge_parent; - this.setCell(pc, pr, props); - return; + if (pc === column && pr === row) { + // Self-referencing merge parent — clear the flag and write directly + existing._merged = false; + existing._merge_parent = undefined; + } else { + this.setCell(pc, pr, props); + return; + } } const cell = existing || this._defaultCell(); diff --git a/src/namespaces/utils.ts b/src/namespaces/utils.ts index e14df8b..a1bc740 100644 --- a/src/namespaces/utils.ts +++ b/src/namespaces/utils.ts @@ -8,6 +8,9 @@ function isPlot(arg: any) { const TYPE_CHECK = { series: (arg) => arg instanceof Series || typeof arg === 'number' || typeof arg === 'string' || typeof arg === 'boolean', string: (arg) => typeof arg === 'string', + // Pine Script color params accept both color strings and `na` (NaN). + // Using 'color' instead of 'string' prevents NaN from invalidating the signature. + color: (arg) => typeof arg === 'string' || (typeof arg === 'number' && isNaN(arg)), number: (arg) => typeof arg === 'number', boolean: (arg) => typeof arg === 'boolean', array: (arg) => Array.isArray(arg), @@ -83,14 +86,27 @@ export function parseArgsForPineParams(args: any[], signatures: any[], types: continue; } - const typeChecker = TYPE_CHECK[types[optionName]]; - if (typeof typeChecker === 'function' && typeChecker(arg)) { + // NaN represents Pine Script's `na` — accept it for numeric and color + // parameters (where na is a valid "no value"). For string/boolean/point + // parameters, NaN should invalidate the signature to prevent multi-sig + // conflicts (e.g., line.new(na,na,na,na) where sig2 maps pos 2 to xloc). + const expectedType = types[optionName]; + if (typeof arg === 'number' && isNaN(arg) && (expectedType === 'number' || expectedType === 'series')) { options[optionName] = arg; } else { - valid[o] = false; + const typeChecker = TYPE_CHECK[types[optionName]]; + if (typeof typeChecker === 'function' && typeChecker(arg)) { + options[optionName] = arg; + } else { + valid[o] = false; + } } } } - return { ...options_arg, ...options, ...override }; + // Named args (options_arg) take precedence over positional matches (options). + // Without this order, multi-signature matching can produce spurious positional + // entries (e.g., NaN at position 2 matching 'border_color' in a secondary + // signature) that overwrite explicit named arguments. + return { ...options, ...options_arg, ...override }; } diff --git a/src/transpiler/analysis/AnalysisPass.ts b/src/transpiler/analysis/AnalysisPass.ts index 952c06f..71aea5e 100644 --- a/src/transpiler/analysis/AnalysisPass.ts +++ b/src/transpiler/analysis/AnalysisPass.ts @@ -123,6 +123,20 @@ export function runAnalysisPass(ast: any, scopeManager: ScopeManager): string | scopeManager.addUserFunction(node.id.name); } }, + // Detect Pine `method` markers emitted by codegen: name.__pineMethod__ = true; + // These mark user functions declared with the `method` keyword, which ARE + // allowed to be called with obj.method() dot-notation. Regular functions + // (without `method`) must NOT be callable via dot-notation. + ExpressionStatement(node: any) { + const expr = node.expression; + if (expr && expr.type === 'AssignmentExpression' && expr.operator === '=' && + expr.left?.type === 'MemberExpression' && + expr.left.property?.name === '__pineMethod__' && + expr.left.object?.type === 'Identifier' && + expr.right?.value === true) { + scopeManager.addUserMethod(expr.left.object.name); + } + }, ArrowFunctionExpression(node: any) { const isRootFunction = node.start === 0; if (isRootFunction && node.params && node.params.length > 0) { @@ -173,6 +187,26 @@ export function runAnalysisPass(ast: any, scopeManager: ScopeManager): string | ], }; + // If the init is an IIFE (switch/if-else expression), wrap its + // array returns in an extra level so $.init() preserves the tuple. + // Without this, $.init() treats flat arrays as time-series and + // takes only the last element, destroying the tuple values. + const initExpr = tempVarDecl.declarations[0].init; + if (initExpr && initExpr.type === 'CallExpression' && + (initExpr.callee.type === 'ArrowFunctionExpression' || + initExpr.callee.type === 'FunctionExpression')) { + walk.simple(initExpr.callee.body, { + ReturnStatement(ret: any) { + if (ret.argument && ret.argument.type === 'ArrayExpression') { + ret.argument = { + type: 'ArrayExpression', + elements: [ret.argument], + }; + } + }, + }); + } + decl.id.elements?.forEach((element: any) => { if (element.type === 'Identifier') { scopeManager.addArrayPatternElement(element.name); diff --git a/src/transpiler/analysis/ScopeManager.ts b/src/transpiler/analysis/ScopeManager.ts index 9064f2a..c71b038 100644 --- a/src/transpiler/analysis/ScopeManager.ts +++ b/src/transpiler/analysis/ScopeManager.ts @@ -61,6 +61,7 @@ export class ScopeManager { private suppressHoisting: boolean = false; private reservedNames: Set = new Set(); private userFunctions: Set = new Set(); + private userMethods: Set = new Set(); public get nextParamIdArg(): any { return { @@ -203,6 +204,14 @@ export class ScopeManager { return this.userFunctions.has(name); } + addUserMethod(name: string): void { + this.userMethods.add(name); + } + + isUserMethod(name: string): boolean { + return this.userMethods.has(name); + } + addVariable(name: string, kind: string): string { // Regular variable handling if (this.isContextBound(name)) { @@ -242,6 +251,29 @@ export class ScopeManager { return [name, 'let']; } + /** + * Check if a variable (by original name) lives inside a function scope. + * Walks the scope stack to find which scope owns the variable, then checks + * whether any scope from the root up to (and including) that level is a + * function scope ('fn'). This allows nested scopes (if, else, for, while) + * inside functions to be correctly treated as function-local. + */ + isVariableInFunctionScope(name: string): boolean { + for (let i = this.scopes.length - 1; i >= 0; i--) { + if (this.scopes[i].has(name)) { + // Variable found at scope level i. + // Check if any scope from root to i is a function scope. + for (let j = 0; j <= i; j++) { + if (this.scopeTypes[j] === 'fn') { + return true; + } + } + return false; + } + } + return false; + } + public generateTempVar(): string { return `temp_${++this.tempVarCounter}`; } diff --git a/src/transpiler/index.ts b/src/transpiler/index.ts index f91227a..5c30485 100644 --- a/src/transpiler/index.ts +++ b/src/transpiler/index.ts @@ -53,7 +53,7 @@ import { injectImplicitImports } from './transformers/InjectionTransformer'; import { normalizeNativeImports } from './transformers/NormalizationTransformer'; import { wrapInContextFunction } from './transformers/WrapperTransformer'; import { transformNestedArrowFunctions, preProcessContextBoundVars, runAnalysisPass } from './analysis/AnalysisPass'; -import { runTransformationPass, transformEqualityChecks } from './transformers/MainTransformer'; +import { runTransformationPass, transformEqualityChecks, propagateAsyncAwait } from './transformers/MainTransformer'; import { extractPineScriptVersion, pineToJS } from './pineToJS/pineToJS.index'; function getPineTSFromSource(source: string | Function): string { @@ -125,6 +125,11 @@ export function transpile(source: string | Function, options: { debug: boolean; // Post-process: transform equality checks to math.__eq calls transformEqualityChecks(ast); + // Post-process: propagate async/await through user-defined function call chains + // Functions containing await (e.g., from request.security) must be async, + // and their callers (via $.call) must await them. + propagateAsyncAwait(ast); + // Generate final code // astring exports baseGenerator (camelCase) in this version/build const baseGenerator = astring.baseGenerator || astring.GENERATOR || ((astring as any).default && (astring as any).default.BASE_GENERATOR); diff --git a/src/transpiler/pineToJS/codegen.ts b/src/transpiler/pineToJS/codegen.ts index 786d467..5183877 100644 --- a/src/transpiler/pineToJS/codegen.ts +++ b/src/transpiler/pineToJS/codegen.ts @@ -4,6 +4,13 @@ // JavaScript Code Generator for PineScript AST // Transforms ESTree-compatible AST into JavaScript code +import { CONTEXT_PINE_VARS } from '../settings'; + +// Set of names that conflict with Pine context variables/namespaces. +// Function parameters with these names must be renamed to avoid Phase 2 +// transpiler incorrectly treating them as namespace references (e.g., color.__value()). +const CONFLICTING_NAMES = new Set(CONTEXT_PINE_VARS); + export class CodeGenerator { private indent: number; private indentStr: string; @@ -12,6 +19,10 @@ export class CodeGenerator { private sourceLines: string[]; private lastCommentedLine: number; private includeSourceComments: boolean; + private paramRenameCounter: number; + // Maps user-defined function names to their ordered parameter names. + // Used to resolve named arguments to correct positional slots. + private functionParams: Map; constructor(options: { indentStr?: string; sourceCode?: string; includeSourceComments?: boolean } = {}) { this.indent = 0; this.indentStr = options.indentStr || ' '; @@ -20,14 +31,20 @@ export class CodeGenerator { this.sourceLines = this.sourceCode ? this.sourceCode.split('\n') : []; this.lastCommentedLine = -1; this.includeSourceComments = options.includeSourceComments || false; // default false + this.paramRenameCounter = 0; + this.functionParams = new Map(); } generate(ast) { this.output = []; this.indent = 0; this.lastCommentedLine = -1; + this.functionParams = new Map(); if (ast.type === 'Program') { + // Pre-scan: collect user-defined function parameter lists and + // detect function names that collide with method call names. + this.preProcessAST(ast); this.generateProgram(ast); } else { throw new Error(`Expected Program node, got ${ast.type}`); @@ -36,6 +53,30 @@ export class CodeGenerator { return this.output.join(''); } + // Pre-scan AST to collect function parameter lists for named-arg resolution. + private preProcessAST(ast: any) { + this.collectFunctionParams(ast); + } + + // Pre-scan AST to collect function parameter names for named-arg resolution. + private collectFunctionParams(node: any) { + if (!node || typeof node !== 'object') return; + if (node.type === 'FunctionDeclaration' && node.id?.name) { + const paramNames: string[] = []; + for (const p of node.params) { + if (p.type === 'Identifier') paramNames.push(p.name); + else if (p.type === 'AssignmentPattern' && p.left?.name) paramNames.push(p.left.name); + } + this.functionParams.set(node.id.name, paramNames); + } + // Recurse into body + if (Array.isArray(node.body)) { + for (const child of node.body) this.collectFunctionParams(child); + } else if (node.body && typeof node.body === 'object') { + this.collectFunctionParams(node.body); + } + } + // Write source code comments writeSourceComment(startLine, endLine = null) { if (!this.sourceLines.length) return; @@ -163,6 +204,38 @@ export class CodeGenerator { this.write('});\n'); } + // Rename Identifier nodes in an AST subtree. + // Walks the pine2js AST and renames all Identifier references that match + // the rename map, stopping at nested FunctionDeclaration boundaries + // (which have their own parameter scope). + private renameIdentifiersInAST(node: any, renameMap: Map) { + if (!node || typeof node !== 'object') return; + + // Rename matching Identifier nodes + if (node.type === 'Identifier' && renameMap.has(node.name)) { + node.name = renameMap.get(node.name); + return; + } + + // Don't recurse into nested function declarations (they have their own scope) + if (node.type === 'FunctionDeclaration') return; + + // Walk all properties of the node + for (const key of Object.keys(node)) { + if (key === 'type') continue; + const val = node[key]; + if (Array.isArray(val)) { + for (const child of val) { + if (child && typeof child === 'object') { + this.renameIdentifiersInAST(child, renameMap); + } + } + } else if (val && typeof val === 'object' && val.type) { + this.renameIdentifiersInAST(val, renameMap); + } + } + } + // Generate FunctionDeclaration generateFunctionDeclaration(node) { this.write(this.indentStr.repeat(this.indent)); @@ -171,6 +244,34 @@ export class CodeGenerator { // Just generate them as regular functions for now, skipping first 'this' param const isMethod = node.id.isMethod; + // Detect function params that collide with Pine context names (namespaces, builtins, etc.) + // and rename them to avoid Phase 2 transpiler misinterpreting them as namespace references. + // e.g., parameter 'color' would be renamed to 'color_$0' to avoid color.__value() injection. + const renameMap = new Map(); + for (const param of node.params) { + const paramName = param.type === 'AssignmentPattern' ? param.left.name : param.name; + if (paramName && CONFLICTING_NAMES.has(paramName)) { + const newName = `${paramName}_$${this.paramRenameCounter++}`; + renameMap.set(paramName, newName); + } + } + + // Apply renaming to param nodes and function body before generating code + if (renameMap.size > 0) { + for (const param of node.params) { + if (param.type === 'AssignmentPattern') { + if (renameMap.has(param.left.name)) { + param.left.name = renameMap.get(param.left.name); + } + // Also rename identifiers in default value expressions + this.renameIdentifiersInAST(param.right, renameMap); + } else if (param.type === 'Identifier' && renameMap.has(param.name)) { + param.name = renameMap.get(param.name); + } + } + this.renameIdentifiersInAST(node.body, renameMap); + } + this.write('function '); this.write(node.id.name); this.write('('); @@ -198,6 +299,14 @@ export class CodeGenerator { this.write(') '); this.generateBlockStatement(node.body, false); this.write('\n'); + + // Emit method marker so the transpile phase can distinguish Pine `method` + // declarations from regular functions. Regular functions must NOT be + // callable via obj.func() dot-notation — only `method` declarations can. + if (isMethod) { + this.write(this.indentStr.repeat(this.indent)); + this.write(`${node.id.name}.__pineMethod__ = true;\n`); + } } // Generate VariableDeclaration @@ -871,20 +980,70 @@ export class CodeGenerator { this.write('('); - for (let i = 0; i < node.arguments.length; i++) { - const arg = node.arguments[i]; + // Check if this is a call to a user-defined function with named arguments. + // Named args are collected into an ObjectExpression as the last argument by the parser. + // For user-defined functions, we need to expand them into the correct positional slots. + const calleeName = node.callee?.type === 'Identifier' ? node.callee.name : null; + const paramList = calleeName ? this.functionParams.get(calleeName) : null; + const lastArg = node.arguments.length > 0 ? node.arguments[node.arguments.length - 1] : null; + const hasNamedArgs = lastArg?.type === 'ObjectExpression' && paramList; + + if (hasNamedArgs) { + // Positional args (everything except the last ObjectExpression) + const positionalArgs = node.arguments.slice(0, -1); + // Named args from the ObjectExpression + const namedArgMap = new Map(); + for (const prop of lastArg.properties) { + const key = prop.key?.name || prop.key?.value; + if (key) namedArgMap.set(key, prop.value); + } - // Handle named arguments (convert to object parameter) - if (arg.type === 'AssignmentExpression' && arg.operator === '=') { - // For named args, we'll just pass the value - // The calling convention would need to be adjusted - this.generateExpression(arg.right); - } else { - this.generateExpression(arg); + // Build the full argument list matching the function's parameter order. + // Start with positional args, then fill in named args at their correct + // parameter positions, using undefined for gaps. + const fullArgs: any[] = []; + let lastFilledIdx = -1; + for (let i = 0; i < paramList.length; i++) { + if (i < positionalArgs.length) { + fullArgs.push(positionalArgs[i]); + lastFilledIdx = i; + } else if (namedArgMap.has(paramList[i])) { + fullArgs.push(namedArgMap.get(paramList[i])); + lastFilledIdx = i; + } else { + fullArgs.push(null); // gap — will emit undefined + } } - if (i < node.arguments.length - 1) { - this.write(', '); + // Trim trailing gaps (no need to emit trailing undefined args) + const trimmedArgs = fullArgs.slice(0, lastFilledIdx + 1); + + for (let i = 0; i < trimmedArgs.length; i++) { + if (trimmedArgs[i] === null) { + this.write('undefined'); + } else { + this.generateExpression(trimmedArgs[i]); + } + if (i < trimmedArgs.length - 1) { + this.write(', '); + } + } + } else { + for (let i = 0; i < node.arguments.length; i++) { + const arg = node.arguments[i]; + + // Handle named arguments (convert to object parameter) + if (arg.type === 'AssignmentExpression' && arg.operator === '=') { + // For named args, we'll just pass the value + // The calling convention would need to be adjusted + this.generateExpression(arg.right); + } else { + this.generateExpression(arg); + } + + if (i < node.arguments.length - 1) { + this.write(', '); + } } } diff --git a/src/transpiler/pineToJS/parser.ts b/src/transpiler/pineToJS/parser.ts index 623875d..341bf89 100644 --- a/src/transpiler/pineToJS/parser.ts +++ b/src/transpiler/pineToJS/parser.ts @@ -353,25 +353,33 @@ export class Parser { // Check for generic parameters: array, map if (this.match(TokenType.OPERATOR, '<')) { this.advance(); // consume '<' - + const typeArgs = []; - + // Parse first type argument (recursive for nested generics) typeArgs.push(this.parseTypeExpression()); - + // Parse additional type arguments (for map) while (this.match(TokenType.COMMA)) { this.advance(); this.skipNewlines(); typeArgs.push(this.parseTypeExpression()); } - + this.expect(TokenType.OPERATOR, '>'); // consume '>' - + // Return as string representation: "array" return baseType + '<' + typeArgs.join(', ') + '>'; } - + + // Handle shorthand array syntax: int[] or int [] (with optional space) + // Pine Script allows both `int[]` and `int []` as array type notation + if (this.match(TokenType.LBRACKET) && this.peek(1).type === TokenType.RBRACKET) { + this.advance(); // consume '[' + this.advance(); // consume ']' + return 'array<' + baseType + '>'; + } + return baseType; // Simple type: "float", "int", etc. } @@ -549,7 +557,24 @@ export class Parser { id.varType = varType; } - return new VariableDeclaration([new VariableDeclarator(id, init, varType)], kind); + const declarators = [new VariableDeclarator(id, init, varType)]; + + // Handle comma-separated var declarations on the same line: + // var int dir = na, var int x1 = na, var float y1 = na + // Each segment after the comma is a full "var type name = expr". + while ( + this.match(TokenType.COMMA) && + this.peek(1).type === TokenType.KEYWORD && + (this.peek(1).value === 'var' || this.peek(1).value === 'varip') + ) { + this.advance(); // consume ',' + // Recursively parse the next "var type name = expr" segment + const extraDecl = this.parseVarDeclaration(); + // Merge declarators from the recursively parsed declaration + declarators.push(...extraDecl.declarations); + } + + return new VariableDeclaration(declarators, kind); } // Lookahead to detect typed variable declaration patterns: @@ -712,6 +737,16 @@ export class Parser { paramType = paramType ? paramType + ' ' + genericType : genericType; } + // Handle array shorthand: int[], float[], line[], label[], etc. + if ( + this.peek().type === TokenType.IDENTIFIER && + this.peek(1).type === TokenType.LBRACKET && + this.peek(2).type === TokenType.RBRACKET + ) { + const arrayType = this.parseTypeExpression(); + paramType = paramType ? paramType + ' ' + arrayType : arrayType; + } + const paramName = this.expect(TokenType.IDENTIFIER).value; const param = new Identifier(paramName); if (paramType) param.varType = paramType; @@ -783,6 +818,16 @@ export class Parser { paramType = paramType ? paramType + ' ' + genericType : genericType; } + // Handle array shorthand: int[], float[], line[], label[], etc. + if ( + this.peek().type === TokenType.IDENTIFIER && + this.peek(1).type === TokenType.LBRACKET && + this.peek(2).type === TokenType.RBRACKET + ) { + const arrayType = this.parseTypeExpression(); + paramType = paramType ? paramType + ' ' + arrayType : arrayType; + } + const paramName = this.expect(TokenType.IDENTIFIER).value; const param = new Identifier(paramName); if (paramType) param.varType = paramType; @@ -921,8 +966,41 @@ export class Parser { // Check for typed variable declaration (series float x = ...) // Also handles: type[] name = ... and type name = ... + // Also handles comma-separated typed declarations: float num = 1.0, float den = 1.0 if (this.peek().type === TokenType.IDENTIFIER && this.isTypedVarDeclaration()) { - return this.parseTypedVarDeclaration(); + const firstDecl = this.parseTypedVarDeclaration(); + + // Check for comma-separated typed declarations on the same line + if (this.match(TokenType.COMMA) && this.peek(1).type === TokenType.IDENTIFIER) { + const declarations: any[] = [firstDecl]; + while (this.match(TokenType.COMMA)) { + this.advance(); // consume comma + this.skipNewlines(true); + if (this.peek().type === TokenType.IDENTIFIER && this.isTypedVarDeclaration()) { + declarations.push(this.parseTypedVarDeclaration()); + } else { + // Not a typed declaration after comma — parse as a regular statement + const expr = this.parseExpression(); + if (this.match(TokenType.OPERATOR)) { + const op = this.peek().value; + if (['=', ':='].includes(op)) { + this.advance(); + this.skipNewlines(true); + const right = this.parseExpression(); + if (op === '=' && expr.type === 'Identifier') { + declarations.push(new VariableDeclaration([new VariableDeclarator(expr, right)], VariableDeclarationKind.LET)); + } else { + declarations.push(new ExpressionStatement(new AssignmentExpression(op === ':=' ? '=' : op, expr, right))); + } + } + } + break; + } + } + return declarations; // Return array of statements + } + + return firstDecl; } // Try to parse as sequence (assignment, assignment, ..., expression) @@ -1374,17 +1452,25 @@ export class Parser { // We'll skip them in specific contexts where they're allowed (like after `.`) // Generic type parameters followed by call: array.new(...) - // We need to skip the generic part and parse the call + // Capture the generic type and, for known types, rewrite + // array.new → array.new_float (same for matrix, etc.) if (this.match(TokenType.OPERATOR, '<')) { // Save position in case this isn't a generic const saved = this.pos; - // Try to parse as generic type + // Try to parse as generic type, capturing type name this.advance(); // consume < let depth = 1; let isGeneric = true; + let genericType = ''; - // Skip until matching > + // Known Pine types that have dedicated new_TYPE methods + const KNOWN_GENERIC_TYPES = new Set([ + 'float', 'int', 'string', 'bool', 'color', + 'line', 'label', 'box', 'linefill', 'table', + ]); + + // Skip until matching >, capturing type identifiers while (depth > 0 && !this.match(TokenType.EOF)) { if (this.match(TokenType.OPERATOR, '<')) { depth++; @@ -1393,6 +1479,10 @@ export class Parser { depth--; this.advance(); } else if (this.match(TokenType.IDENTIFIER) || this.match(TokenType.COMMA) || this.match(TokenType.DOT)) { + // Capture only top-level, simple type names (depth === 1) + if (depth === 1 && this.match(TokenType.IDENTIFIER) && genericType === '') { + genericType = this.peek().value; + } this.advance(); } else { // Not a generic type, restore position @@ -1402,6 +1492,15 @@ export class Parser { } } + // For known types, rewrite callee: array.new → array.new_float + // Only for array/matrix (not map, which uses map.new with two type params) + if (isGeneric && expr.type === 'MemberExpression' + && expr.property.name === 'new' + && (expr.object.name === 'array' || expr.object.name === 'matrix') + && KNOWN_GENERIC_TYPES.has(genericType)) { + expr.property = new Identifier('new_' + genericType); + } + // If we successfully parsed generic and next is (, parse call if (isGeneric && this.match(TokenType.LPAREN)) { expr = this.parseCallExpression(expr); @@ -1422,11 +1521,24 @@ export class Parser { else if (this.match(TokenType.DOT)) { this.advance(); this.skipNewlines(); // Allow method chaining across lines - const property = this.expect(TokenType.IDENTIFIER).value; - expr = new MemberExpression(expr, new Identifier(property), false); + // Accept both IDENTIFIER and KEYWORD after DOT — keywords like + // 'type' can be valid property names (e.g., syminfo.type) + const propToken = this.peek(); + if (propToken.type !== TokenType.IDENTIFIER && propToken.type !== TokenType.KEYWORD) { + throw new Error(`Expected property name but got ${propToken.type} at ${propToken.line}:${propToken.column}`); + } + this.advance(); + expr = new MemberExpression(expr, new Identifier(propToken.value), false); } // Index/history operator else if (this.match(TokenType.LBRACKET)) { + // If this looks like tuple destructuring [a, b, c] = ..., it's a new + // statement, not a postfix index on the previous expression. + // This happens after block expressions like switch where DEDENT is + // immediately followed by LBRACKET with no intervening NEWLINE. + if (this.isTupleDestructuring()) { + break; + } this.advance(); this.skipNewlines(); const index = this.parseExpression(); diff --git a/src/transpiler/settings.ts b/src/transpiler/settings.ts index 4d08506..9273e3b 100644 --- a/src/transpiler/settings.ts +++ b/src/transpiler/settings.ts @@ -1,9 +1,26 @@ // Known Pine Script namespaces that might be used as functions or objects -export const KNOWN_NAMESPACES = ['ta', 'math', 'request', 'array', 'input']; +export const KNOWN_NAMESPACES = ['ta', 'math', 'request', 'array', 'input', 'color']; // This is used to transform ns() calls to ns.any() calls // Entries with a __value property also support dual-use as variables (e.g. time, na) -export const NAMESPACES_LIKE = ['hline', 'plot', 'fill', 'label', 'line', 'na', 'time', 'time_close', 'dayofmonth', 'dayofweek', 'hour', 'minute', 'month', 'second', 'weekofyear', 'year']; +export const NAMESPACES_LIKE = [ + 'hline', + 'plot', + 'fill', + 'label', + 'line', + 'na', + 'time', + 'time_close', + 'dayofmonth', + 'dayofweek', + 'hour', + 'minute', + 'month', + 'second', + 'weekofyear', + 'year', +]; // Async methods that require await keyword (format: 'namespace.method') export const ASYNC_METHODS = ['request.security', 'request.security_lower_tf']; @@ -11,7 +28,17 @@ export const ASYNC_METHODS = ['request.security', 'request.security_lower_tf']; // Factory methods that create objects with side effects (format: 'namespace.method') // When used inside `var` declarations, these calls are wrapped in arrow functions // so they are only evaluated on bar 0 (deferred evaluation via initVar thunk). -export const FACTORY_METHODS = ['line.new', 'line.copy', 'label.new', 'label.copy', 'polyline.new', 'box.new', 'box.copy', 'table.new']; +export const FACTORY_METHODS = [ + 'line.new', + 'line.copy', + 'label.new', + 'label.copy', + 'polyline.new', + 'box.new', + 'box.copy', + 'table.new', + 'linefill.new', +]; // All known data variables in the context export const CONTEXT_DATA_VARS = ['open', 'high', 'low', 'close', 'volume', 'hl2', 'hlc3', 'ohlc4', 'hlcc4', 'openTime', 'closeTime']; @@ -39,9 +66,11 @@ export const CONTEXT_PINE_VARS = [ // 'alertcondition', + 'alert', + 'error', + 'max_bars_back', 'fixnan', 'na', - 'color', 'nz', 'timestamp', 'str', diff --git a/src/transpiler/transformers/ExpressionTransformer.ts b/src/transpiler/transformers/ExpressionTransformer.ts index 2ab9251..7c7d160 100644 --- a/src/transpiler/transformers/ExpressionTransformer.ts +++ b/src/transpiler/transformers/ExpressionTransformer.ts @@ -14,8 +14,13 @@ const UNDEFINED_ARG = { export function createScopedVariableReference(name: string, scopeManager: ScopeManager): any { const [scopedName, kind] = scopeManager.getVariable(name); - // Check if function scoped and not $$ itself - if (scopedName.match(/^fn\d+_/) && name !== '$$') { + // Check if function scoped (directly or in a nested scope within a function) + // and not $$ itself. Variables in nested scopes (if, else, for) inside + // functions get names like `if4_nFibL` that don't start with `fn\d+_`, + // so we also ask the ScopeManager whether the variable lives inside a + // function scope. + const isInFnScope = scopedName.match(/^fn\d+_/) || scopeManager.isVariableInFunctionScope(name); + if (isInFnScope && name !== '$$') { const [localCtxName] = scopeManager.getVariable('$$'); // Only if $$ is actually found (it should be in function scope) if (localCtxName) { @@ -101,6 +106,16 @@ export function transformArrayIndex(node: any, scopeManager: ScopeManager): void } } } + + // Handle complex index expressions (BinaryExpression, UnaryExpression, etc.) + // when neither block above matched — e.g. func()[expr * 2], close[a + b] with non-Identifier object. + if (node.computed && node.property.type !== 'Identifier' && node.property.type !== 'MemberExpression' + && !node._indexTransformed) { + if (node.property.type === 'BinaryExpression' || node.property.type === 'UnaryExpression' || + node.property.type === 'LogicalExpression' || node.property.type === 'ConditionalExpression') { + node.property = transformOperand(node.property, scopeManager); + } + } } export function addArrayAccess(node: any, scopeManager: ScopeManager): void { @@ -830,6 +845,12 @@ export function transformFunctionArgument(arg: any, namespace: string, scopeMana // It's a data variable like 'close', 'open' - use directly return element; } + // Function parameters should use raw identifier wrapped in $.get() + // (same pattern as non-array function param handling elsewhere) + if (scopeManager.isLocalSeriesVar(element.name)) { + const plainIdentifier = ASTFactory.createIdentifier(element.name); + return ASTFactory.createGetCall(plainIdentifier, 0); + } // It's a user variable - transform to context reference return createScopedVariableAccess(element.name, scopeManager); } @@ -866,11 +887,18 @@ export function transformFunctionArgument(arg: any, namespace: string, scopeMana ? arg.object : transformIdentifierForParam(arg.object, scopeManager); - // Transform the index if it's an identifier, and unwrap to scalar via $.get(..., 0) - const transformedProperty = - arg.property.type === 'Identifier' && !scopeManager.isContextBound(arg.property.name) && !scopeManager.isLoopVariable(arg.property.name) - ? ASTFactory.createGetCall(transformIdentifierForParam(arg.property, scopeManager), 0) - : arg.property; + // Transform the index expression and unwrap to scalar via $.get(..., 0) + let transformedProperty: any; + if (arg.property.type === 'Identifier' && !scopeManager.isContextBound(arg.property.name) && !scopeManager.isLoopVariable(arg.property.name)) { + transformedProperty = ASTFactory.createGetCall(transformIdentifierForParam(arg.property, scopeManager), 0); + } else if (arg.property.type === 'BinaryExpression' || arg.property.type === 'UnaryExpression' || + arg.property.type === 'LogicalExpression' || arg.property.type === 'ConditionalExpression') { + // Recursively transform identifiers inside complex index expressions + // e.g. close[strideInput * 2] → ta.param(close, $.get($.let.glb1_strideInput, 0) * 2, 'p2') + transformedProperty = transformOperand(arg.property, scopeManager, namespace); + } else { + transformedProperty = arg.property; + } const memberExpr = ASTFactory.createMemberExpression(ASTFactory.createIdentifier(namespace), ASTFactory.createIdentifier('param')); @@ -946,8 +974,8 @@ export function transformFunctionArgument(arg: any, namespace: string, scopeMana // Only transform if the variable has been renamed (i.e., it's a user-defined variable) // Context-bound variables that are NOT renamed (like 'display', 'ta', 'input') should NOT be transformed if (isRenamed && !scopeManager.isLoopVariable(name)) { - // Transform object to $.get($.let.varName, 0) - const contextVarRef = ASTFactory.createContextVariableReference(kind, varName); + // Transform object to $.get($.let.varName, 0) or $$.get($$.let.varName, 0) for function scope + const contextVarRef = createScopedVariableReference(name, scopeManager); const getCall = ASTFactory.createGetCall(contextVarRef, 0); arg.object = getCall; } @@ -967,8 +995,12 @@ export function transformFunctionArgument(arg: any, namespace: string, scopeMana arg.properties = arg.properties.map((prop: any) => { // Get the variable name and kind if (prop.value.name) { - // If it's a context-bound variable (like 'close', 'open') and not a root param - if (scopeManager.isContextBound(prop.value.name) && !scopeManager.isRootParam(prop.value.name)) { + // If it's a context-bound variable (like 'close', 'open'), a local series + // var (non-root function parameter like 'col' in in_out()), or a loop + // variable — use the raw identifier, not a scoped reference. + if (scopeManager.isContextBound(prop.value.name) || + scopeManager.isLocalSeriesVar(prop.value.name) || + scopeManager.isLoopVariable(prop.value.name)) { return { type: 'Property', key: { @@ -1071,6 +1103,31 @@ export function transformFunctionArgument(arg: any, namespace: string, scopeMana return paramCall; } +/** Check if a $.get() call exists anywhere in a MemberExpression/CallExpression chain */ +function hasGetCallInChain(node: any): boolean { + if (!node) return false; + if (isDirectGetCall(node)) return true; + if (node.type === 'MemberExpression') return hasGetCallInChain(node.object); + // Traverse through ChainExpression wrappers created by earlier optional chaining passes + if (node.type === 'ChainExpression') return hasGetCallInChain(node.expression); + // Traverse through intermediate CallExpression nodes (e.g. aEW.get(0).b5.method()) + if (node.type === 'CallExpression') { + const callee = node.callee; + if (callee?.type === 'MemberExpression') return hasGetCallInChain(callee.object); + // Callee may already be wrapped in ChainExpression by a prior pass + if (callee?.type === 'ChainExpression') return hasGetCallInChain(callee.expression); + } + return false; +} + +/** Check if a node is directly a $.get(...) call (not nested in a chain) */ +function isDirectGetCall(node: any): boolean { + return node?.type === 'CallExpression' && + node.callee?.type === 'MemberExpression' && + node.callee.object?.name === '$' && + node.callee.property?.name === 'get'; +} + /** * Recursively resolves identifiers in a callee object chain. * Handles patterns like: obj.get(i).out.method() where obj is a user variable @@ -1274,7 +1331,25 @@ export function transformCallExpression(node: any, scopeManager: ScopeManager, n // Check if methodName is a user-defined function (and not a built-in property like push/pop/size unless shadowed?) const isUserFunction = scopeManager.isUserFunction(methodName); - if (isUserFunction && !scopeManager.isContextBound(methodName)) { + // Guard: if the object is a function parameter, this is a built-in method + // call on a typed argument (e.g. t.cell() where t is a table param), + // NOT a call to the user function with the same name. Skip transformation. + const _obj = node.callee.object; + const isBuiltinMethodOnParam = _obj.type === 'Identifier' && scopeManager.isLocalSeriesVar(_obj.name); + + // Guard: if the callee object is a MemberExpression (property chain like + // aZZ.x.set(0, val)), this is a method call on a sub-property, NOT a user + // function call. User function method calls only happen on direct variable + // references (e.g. obj.method(args) where obj is an Identifier). + const isChainedPropertyMethod = _obj.type === 'MemberExpression'; + + // Only allow obj.method(args) → method(obj, args) for functions declared + // with the Pine `method` keyword. Regular functions (without `method`) + // must NOT be callable via dot-notation — obj.func() is always a built-in + // method call on the object, never a call to a user-defined function. + const isUserMethod = scopeManager.isUserMethod(methodName); + + if (isUserFunction && isUserMethod && !scopeManager.isContextBound(methodName) && !isBuiltinMethodOnParam && !isChainedPropertyMethod) { // It's a user variable/function. // Transform obj.method(args) -> method(obj, args) // 1. Get the object (first arg) @@ -1313,7 +1388,10 @@ export function transformCallExpression(node: any, scopeManager: ScopeManager, n // But here 'method' is just the property name node. We need an Identifier for the function. // Since function declarations are not renamed in transformFunctionDeclaration and are local identifiers, // we should use the identifier directly. + // Mark with _skipTransformation to prevent the identifier from being resolved + // to a same-named variable (e.g. `isSame2` function vs `isSame2` variable). const functionRef = ASTFactory.createIdentifier(methodName); + functionRef._skipTransformation = true; const newArgs = [functionRef, callId, transformedObj, ...transformedArgs]; @@ -1330,6 +1408,7 @@ export function transformCallExpression(node: any, scopeManager: ScopeManager, n // recursively resolve inner identifiers and calls resolveCalleeObject(node.callee.object, node.callee, scopeManager); } + } // Transform any nested call expressions in the arguments @@ -1398,4 +1477,61 @@ export function transformCallExpression(node: any, scopeManager: ScopeManager, n } ); }); + + // --------------------------------------------------------------------------- + // Optional chaining for method calls on values retrieved via $.get(). + // + // In Pine Script, calling methods on `na` (e.g. `na.delete()`, `na.set_x1()`) + // is a silent no-op. At runtime, `na` is represented as NaN. Since NaN is not + // null/undefined, single optional chaining (`NaN?.method()`) still crashes + // because `NaN.method` evaluates to `undefined`, then `undefined()` throws. + // Double optional chaining (`NaN?.method?.()`) is needed: + // NaN?.method → undefined (NaN is not nullish, so .method is accessed → undefined) + // undefined?.() → undefined (short-circuits, no crash) + // + // Two cases are handled: + // + // 1) Direct: $.get(X, N).method() → $.get(X, N)?.method?.() + // Occurs when a `var` drawing variable is initialized to `na`: + // var polyline profilePoly = na → $.initVar($.var.glb1_profilePoly, NaN) + // profilePoly.delete() → $.get($.var.glb1_profilePoly, 0).delete() + // $.get() returns NaN, and .delete() on NaN throws without optional chaining. + // + // 2) Chained: $.get(X, N).field.method() → $.get(X, N).field?.method?.() + // Occurs when a UDT drawing field is `na`: + // myUDT.boxField.delete() → $.get(udt, 0).boxField.delete() + // The field resolves to NaN, same issue. + // + // NOTE: This must run AFTER argument transformation so that the callee is + // still a MemberExpression when argument type checks inspect it. + // + // CRITICAL — DO NOT broaden this condition to `hasGetCallInChain(node.callee)`. + // That matches intermediate calls (e.g. `$.get(arr,0).get(0)` in + // `arr.get(0).field.method()`) instead of the LEAF method call. Once an + // intermediate call is wrapped in ChainExpression, the leaf call can no + // longer find $.get() in its chain and misses optional chaining entirely. + // The two cases below are intentionally separated: + // Case 1: callee.object IS the $.get() call directly (direct pattern) + // Case 2: callee.object is a MemberExpression with $.get() deeper in chain (chained pattern) + // --------------------------------------------------------------------------- + if (node.callee && node.callee.type === 'MemberExpression') { + const calleeObj = node.callee.object; + // Case 1 — Direct: $.get(X, N).method() + // callee.object is the $.get() CallExpression itself + const isDirect = isDirectGetCall(calleeObj); + // Case 2 — Chained: $.get(X, N).field.method() + // callee.object is a MemberExpression (the .field access), with $.get() deeper + const isChained = calleeObj?.type === 'MemberExpression' && hasGetCallInChain(calleeObj); + + if (isDirect || isChained) { + // Double optional chaining: obj?.method?.() + // The node stays as a CallExpression (safe for AST walkers) but gets: + // 1. optional: true on the CallExpression → produces ?.() + // 2. optional: true on the MemberExpression → produces ?.method + // 3. callee wrapped in ChainExpression → groups the chain for astring + const innerCallee = Object.assign({}, node.callee, { optional: true }); + node.callee = { type: 'ChainExpression', expression: innerCallee }; + node.optional = true; + } + } } diff --git a/src/transpiler/transformers/MainTransformer.ts b/src/transpiler/transformers/MainTransformer.ts index becdad8..c62d730 100644 --- a/src/transpiler/transformers/MainTransformer.ts +++ b/src/transpiler/transformers/MainTransformer.ts @@ -15,22 +15,170 @@ import { transformFunctionDeclaration, } from './StatementTransformer'; +/** + * Post-pass: propagate async/await through user-defined function call chains. + * + * When request.security() is used inside a user-defined function, the transpiler + * injects `await` but doesn't mark the function as `async` or propagate await + * to callers via $.call(). This pass: + * 1. Finds all FunctionDeclarations containing AwaitExpression (directly, not in nested functions) + * 2. Marks them as async + * 3. Wraps $.call(fn, ...) invocations of those functions in AwaitExpression + * 4. Repeats until stable (handles transitive async infection: A calls B calls request.security) + */ +export function propagateAsyncAwait(ast: any): void { + const baseVisitor = { ...walk.base, LineComment: () => {} }; + + // Helper: extract function name from $.call() first argument + // Handles both: $.call(funcName, ...) and $.call($.get(funcName, 0), ...) + function getCallTargetName(arg: any): string | null { + if (!arg) return null; + if (arg.type === 'Identifier') return arg.name; + if (arg.type === 'CallExpression' && + arg.callee?.type === 'MemberExpression' && + arg.callee.object?.name === '$' && + arg.callee.property?.name === 'get' && + arg.arguments?.[0]?.type === 'Identifier') { + return arg.arguments[0].name; + } + return null; + } + + // Step 1: Collect all function declarations by name + const funcDecls = new Map(); + walk.simple(ast, { + FunctionDeclaration(node: any) { + if (node.id?.name) funcDecls.set(node.id.name, node); + }, + }, baseVisitor); + + // Helper: check if a function body contains AwaitExpression at its own scope + // (not descending into nested functions — each function is its own async scope) + function bodyContainsAwait(body: any): boolean { + let found = false; + // Custom walker that stops at function boundaries + const scopedVisitor = { + ...baseVisitor, + // Override function types to NOT descend + FunctionDeclaration: () => {}, + FunctionExpression: () => {}, + ArrowFunctionExpression: () => {}, + }; + walk.simple(body, { + AwaitExpression() { found = true; }, + }, scopedVisitor); + return found; + } + + // Step 2: Iterate until stable — propagate async through the call chain + let changed = true; + let iterations = 0; + while (changed && iterations < 20) { + changed = false; + iterations++; + + // 2a: Mark arrow/function expressions as async if their body contains await + walk.simple(ast, { + ArrowFunctionExpression(node: any) { + if (!node.async && bodyContainsAwait(node.body)) { + node.async = true; + changed = true; + } + }, + FunctionExpression(node: any) { + if (!node.async && bodyContainsAwait(node.body)) { + node.async = true; + changed = true; + } + }, + }, baseVisitor); + + // 2b: Wrap async IIFE calls in await + // Pattern: (async () => {...})() returns a Promise → needs await + const iifeToWrap: any[] = []; + walk.simple(ast, { + CallExpression(node: any) { + if (!node._asyncWrapped && + (node.callee?.type === 'ArrowFunctionExpression' || + node.callee?.type === 'FunctionExpression') && + node.callee.async === true) { + iifeToWrap.push(node); + } + }, + }, baseVisitor); + for (const node of iifeToWrap) { + const clone: any = {}; + for (const k of Object.keys(node)) { clone[k] = node[k]; } + clone._asyncWrapped = true; + for (const k of Object.keys(node)) { delete node[k]; } + node.type = 'AwaitExpression'; + node.argument = clone; + changed = true; + } + + // 2c: Find named functions containing await, mark them async + const asyncFuncNames = new Set(); + for (const [name, decl] of funcDecls) { + if (bodyContainsAwait(decl.body)) { + if (!decl.async) { + decl.async = true; + changed = true; + } + asyncFuncNames.add(name); + } + } + + // 2d: Wrap $.call(asyncFunc, ...) invocations in await + if (asyncFuncNames.size > 0) { + const toWrap: any[] = []; + walk.simple(ast, { + CallExpression(node: any) { + if (!node._asyncWrapped && + node.callee?.type === 'MemberExpression' && + node.callee.object?.name === '$' && + node.callee.property?.name === 'call' && + node.arguments?.length > 0) { + const targetName = getCallTargetName(node.arguments[0]); + if (targetName && asyncFuncNames.has(targetName)) { + toWrap.push(node); + } + } + }, + }, baseVisitor); + + for (const node of toWrap) { + const clone: any = {}; + for (const k of Object.keys(node)) { clone[k] = node[k]; } + clone._asyncWrapped = true; + for (const k of Object.keys(node)) { delete node[k]; } + node.type = 'AwaitExpression'; + node.argument = clone; + changed = true; + } + } + } +} + export function transformEqualityChecks(ast: any): void { const baseVisitor = { ...walk.base, LineComment: () => {} }; walk.simple( ast, { BinaryExpression(node: any) { - // Check if this is an equality operator + // Transform equality/inequality operators to na-aware versions. + // In Pine Script, any comparison with na returns false: + // na == na → false, na != na → false + // 1 == na → false, 1 != na → false + // JavaScript's != treats NaN specially (NaN != x is always true), + // so we route through math.__eq / math.__neq which check for NaN + // and return false when either operand is na. if (node.operator === '==' || node.operator === '===') { - // Store the original operands - const leftOperand = node.left; - const rightOperand = node.right; - - // Transform the BinaryExpression into a CallExpression - const callExpr = ASTFactory.createMathEqCall(leftOperand, rightOperand); + const callExpr = ASTFactory.createMathEqCall(node.left, node.right); + callExpr._transformed = true; + Object.assign(node, callExpr); + } else if (node.operator === '!=' || node.operator === '!==') { + const callExpr = ASTFactory.createMathNeqCall(node.left, node.right); callExpr._transformed = true; - Object.assign(node, callExpr); } }, @@ -101,7 +249,24 @@ export function runTransformationPass( node.body = newBody; // state.popScope(); }, - ReturnStatement(node: any, state: ScopeManager) { + ReturnStatement(node: any, state: ScopeManager, c: any) { + // Walk into return argument for types not handled by transformReturnStatement. + // transformReturnStatement has two handling phases: + // Phase 1 (always): ArrayExpression, ObjectExpression, Identifier, MemberExpression + // Phase 2 (curScope==='fn' only): BinaryExpression, LogicalExpression, + // ConditionalExpression, CallExpression — uses its own walk.recursive + // When curScope !== 'fn' (e.g. return inside if/else within a function), + // Phase 2 is skipped and complex expression types go untransformed. + // We call c() to walk those cases, but ONLY when Phase 2 won't run, + // to avoid double-transformation. + if (node.argument && + node.argument.type !== 'ArrayExpression' && + node.argument.type !== 'ObjectExpression' && + node.argument.type !== 'Identifier' && + node.argument.type !== 'MemberExpression' && + state.getCurrentScopeType() !== 'fn') { + c(node.argument, state); + } transformReturnStatement(node, state); }, VariableDeclaration(node: any, state: ScopeManager) { diff --git a/src/transpiler/transformers/StatementTransformer.ts b/src/transpiler/transformers/StatementTransformer.ts index f4f8277..b2a6d5c 100644 --- a/src/transpiler/transformers/StatementTransformer.ts +++ b/src/transpiler/transformers/StatementTransformer.ts @@ -663,26 +663,41 @@ export function transformForStatement(node: any, scopeManager: ScopeManager, c: }; // Transform any identifiers in the init expression + // Must wrap Series identifiers in $.get() so the loop variable receives + // the concrete value, not a raw Series object (e.g. `for i = bar_index to 0`). if (decl.init) { walk.recursive(decl.init, scopeManager, { Identifier(node: any, state: ScopeManager) { - if (!scopeManager.isLoopVariable(node.name)) { + if (!scopeManager.isLoopVariable(node.name) && !node.computed) { scopeManager.pushScope('for'); transformIdentifier(node, state); + if (node.type === 'Identifier') { + const isNamespaceObject = + scopeManager.isContextBound(node.name) && + node.parent && + node.parent.type === 'MemberExpression' && + node.parent.object === node; + if (!isNamespaceObject) { + node.computed = true; + addArrayAccess(node, state); + } + } scopeManager.popScope(); } }, - MemberExpression(node: any) { + MemberExpression(node: any, state: ScopeManager, c: any) { scopeManager.pushScope('for'); transformMemberExpression(node, '', scopeManager); scopeManager.popScope(); + if (node.type === 'MemberExpression' && node.object) { + if (node.object.type !== 'Identifier' || !scopeManager.isContextBound(node.object.name)) { + c(node.object, state); + } + } }, CallExpression(node: any, state: ScopeManager, c: any) { - // Set parent on callee so transformMemberExpression knows it's already being called - // (prevents auto-call conversion: e.g. array.size -> array.size()) node.callee.parent = node; c(node.callee, state); - // Traverse arguments so identifiers get transformed for (const arg of node.arguments) { c(arg, state); } @@ -744,15 +759,47 @@ export function transformForStatement(node: any, scopeManager: ScopeManager, c: } // Transform update expression + // Must mirror the test condition walker: wrap Series identifiers in $.get(), + // handle MemberExpression chains and CallExpression arguments. + // Without this, `for i = 0 to bar_index - X` produces raw Series objects + // in the update ternary, causing NaN comparisons and infinite loops. if (node.update) { walk.recursive(node.update, scopeManager, { Identifier(node: any, state: ScopeManager) { - if (!scopeManager.isLoopVariable(node.name)) { + if (!scopeManager.isLoopVariable(node.name) && !node.computed) { scopeManager.pushScope('for'); transformIdentifier(node, state); + if (node.type === 'Identifier') { + const isNamespaceObject = + scopeManager.isContextBound(node.name) && + node.parent && + node.parent.type === 'MemberExpression' && + node.parent.object === node; + if (!isNamespaceObject) { + node.computed = true; + addArrayAccess(node, state); + } + } scopeManager.popScope(); } }, + MemberExpression(node: any, state: ScopeManager, c: any) { + scopeManager.pushScope('for'); + transformMemberExpression(node, '', scopeManager); + scopeManager.popScope(); + if (node.type === 'MemberExpression' && node.object) { + if (node.object.type !== 'Identifier' || !scopeManager.isContextBound(node.object.name)) { + c(node.object, state); + } + } + }, + CallExpression(node: any, state: ScopeManager, c: any) { + node.callee.parent = node; + c(node.callee, state); + for (const arg of node.arguments) { + c(arg, state); + } + }, }); } @@ -776,11 +823,24 @@ export function transformWhileStatement(node: any, scopeManager: ScopeManager, c scopeManager.setSuppressHoisting(true); // Transform the test condition + // Must wrap Series identifiers in $.get() so comparisons use concrete + // values, not raw Series objects (e.g. `while bar_index > cnt`). if (node.test) { walk.recursive(node.test, scopeManager, { Identifier(node: any, state: ScopeManager) { if (!node.computed) { transformIdentifier(node, state); + if (node.type === 'Identifier') { + const isNamespaceObject = + scopeManager.isContextBound(node.name) && + node.parent && + node.parent.type === 'MemberExpression' && + node.parent.object === node; + if (!isNamespaceObject) { + node.computed = true; + addArrayAccess(node, state); + } + } } }, MemberExpression(node: any, state: ScopeManager, c: any) { diff --git a/src/transpiler/utils/ASTFactory.ts b/src/transpiler/utils/ASTFactory.ts index 660ec2c..13ed1ec 100644 --- a/src/transpiler/utils/ASTFactory.ts +++ b/src/transpiler/utils/ASTFactory.ts @@ -142,6 +142,14 @@ export const ASTFactory = { return this.createCallExpression(eqMethod, [left, right]); }, + // Create $.pine.math.__neq(left, right) + createMathNeqCall(left: any, right: any): any { + const pineObj = this.createMemberExpression(this.createContextIdentifier(), this.createIdentifier('pine'), false); + const mathObj = this.createMemberExpression(pineObj, this.createIdentifier('math'), false); + const neqMethod = this.createMemberExpression(mathObj, this.createIdentifier('__neq'), false); + return this.createCallExpression(neqMethod, [left, right]); + }, + createWrapperFunction(body: any): any { return { type: 'FunctionDeclaration', diff --git a/tests/core/udt-drawing-objects.test.ts b/tests/core/udt-drawing-objects.test.ts index 2d4f8af..d763d87 100644 --- a/tests/core/udt-drawing-objects.test.ts +++ b/tests/core/udt-drawing-objects.test.ts @@ -280,6 +280,112 @@ plot(close) // Non-var UDT with drawing objects (let — no thunk wrapping) // ---------------------------------------------------------------- + // ---------------------------------------------------------------- + // Uninitialized drawing fields: method calls return na (not crash) + // ---------------------------------------------------------------- + + it('UDT with uninitialized box field: method call returns na (not crash)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("na box test", overlay=true) +type MyObj + box bx +var MyObj obj = MyObj.new() +plot(obj.bx.get_top()) +`; + // In Pine Script, obj.bx is na → get_top() returns na → plot shows gap (no crash) + // The key assertion is that run() completes without throwing + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + }); + + it('UDT with uninitialized line field: method call returns na (not crash)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("na line test", overlay=true) +type MyObj + line ln +var MyObj obj = MyObj.new() +plot(obj.ln.get_x1()) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + }); + + it('UDT with uninitialized label field: method call returns na (not crash)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("na label test", overlay=true) +type MyObj + label lb +var MyObj obj = MyObj.new() +plot(obj.lb.get_x()) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + }); + + // ---------------------------------------------------------------- + // Linefill instance methods on UDT fields + // ---------------------------------------------------------------- + + it('LinefillObject instance methods: get_line1/get_line2/set_color', async () => { + const pineTS = makePineTS(); + + const { result } = await pineTS.run((context) => { + const { Type, line, linefill } = context.pine; + + const l1 = line.new(0, 100, 10, 200); + const l2 = line.new(0, 300, 10, 400); + const lf = linefill.new(l1, l2, '#0000ff'); + + // Instance methods + const gotLine1 = lf.get_line1(); + const gotLine2 = lf.get_line2(); + const isSameLine1 = gotLine1 === l1; + const isSameLine2 = gotLine2 === l2; + + // set_color instance method + lf.set_color('#ff0000'); + const newColor = lf.color; + + return { isSameLine1, isSameLine2, newColor }; + }); + + expect(result.isSameLine1[0]).toBe(true); + expect(result.isSameLine2[0]).toBe(true); + expect(result.newColor[0]).toBe('#ff0000'); + }); + + it('UDT with linefill field: get_line1().set_xy1() chain works (Pine source)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("linefill chain", overlay=true) +type MyObj + linefill lf + +var l1 = line.new(0, 100, 10, 200) +var l2 = line.new(0, 300, 10, 400) +var MyObj obj = MyObj.new( + lf = linefill.new(l1, l2, "#0000ff") +) +obj.lf.get_line1().set_xy1(bar_index - 5, close) +obj.lf.get_line2().set_xy1(bar_index - 5, open) +obj.lf.set_color("#ff0000") +plot(close) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + }); + + // ---------------------------------------------------------------- + // Non-var UDT with drawing objects (let — no thunk wrapping) + // ---------------------------------------------------------------- + it('let UDT with line field works without thunks', async () => { const pineTS = makePineTS(); diff --git a/tests/core/udt-method-drawing-repro.test.ts b/tests/core/udt-method-drawing-repro.test.ts new file mode 100644 index 0000000..6064592 --- /dev/null +++ b/tests/core/udt-method-drawing-repro.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { PineTS, Provider } from 'index'; + +describe('UDT method with drawing fields (repro)', () => { + const makePineTS = () => + new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2019-01-01').getTime(), new Date('2019-03-01').getTime()); + + it('method calling set_xy1 on UDT line field', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("method set_xy1 test", overlay=true) +type MyFib + line ln1 +method setLines(MyFib f, int x1, int x2, float y) => + f.ln1.set_xy1(x1, y) + f.ln1.set_xy2(x2, y) +var MyFib fib = MyFib.new( + ln1 = line.new(na, na, na, na, color=#ff0000) +) +fib.setLines(bar_index - 5, bar_index, close) +plot(close) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + }); + + it('linefill.get_line1().set_xy1() chain on UDT field (Elliott Wave pattern)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("linefill chain repro", overlay=true) +type fibL + line wave1 + linefill l_fill_ + +var fibL nFibL = fibL.new( + wave1 = line.new(na, na, na, na, color=#ff0000), + l_fill_ = linefill.new(line.new(na, na, na, na), line.new(na, na, na, na), color.new(color.red, 90)) +) +nFibL.l_fill_.get_line1().set_xy1(bar_index - 5, close) +plot(close) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + }); + + it('var UDT inside if block — variable accessible within if scope (nFibL pattern)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("var inside if", overlay=true) +type MyObj + line ln + bool flag = false + +method setLines(MyObj obj, int x1, int x2, float y) => + obj.ln.set_xy1(x1, y) + obj.ln.set_xy2(x2, y) + +draw(enabled) => + if enabled + var MyObj nObj = MyObj.new( + ln = line.new(na, na, na, na, color=#ff0000) + ) + nObj.setLines(bar_index - 5, bar_index, close) + nObj.flag := true + nObj.ln.set_xy1(bar_index - 3, close) + +draw(true) +plot(close) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + }); +}); diff --git a/tests/namespaces/color/color.test.ts b/tests/namespaces/color/color.test.ts new file mode 100644 index 0000000..a70e784 --- /dev/null +++ b/tests/namespaces/color/color.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, it } from 'vitest'; +import PineTS from '../../../src/PineTS.class'; +import { Provider } from '../../../src/marketData/Provider.class'; + +const last = (arr: any[]) => arr[arr.length - 1]; + +describe('Color Namespace', () => { + const sDate = new Date('2019-01-01').getTime(); + const eDate = new Date('2019-01-05').getTime(); + + // ── Predefined color constants ────────────────────────────────── + + it('should return all 17 predefined color constants', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + // Note: color constants are methods (not getters) because the transpiler + // converts `color.white` → `color.white()` via KNOWN_NAMESPACES. + // In tests (no transpilation), we must call them explicitly. + const sourceCode = (context: any) => { + const { color } = context.pine; + return { + aqua: color.aqua(), + black: color.black(), + blue: color.blue(), + fuchsia: color.fuchsia(), + gray: color.gray(), + green: color.green(), + lime: color.lime(), + maroon: color.maroon(), + navy: color.navy(), + olive: color.olive(), + orange: color.orange(), + purple: color.purple(), + red: color.red(), + silver: color.silver(), + teal: color.teal(), + white: color.white(), + yellow: color.yellow(), + }; + }; + + const { result } = await pineTS.run(sourceCode); + + expect(last(result.aqua)).toBe('#00BCD4'); + expect(last(result.black)).toBe('#363A45'); + expect(last(result.blue)).toBe('#2196F3'); + expect(last(result.fuchsia)).toBe('#E040FB'); + expect(last(result.gray)).toBe('#787B86'); + expect(last(result.green)).toBe('#4CAF50'); + expect(last(result.lime)).toBe('#00E676'); + expect(last(result.maroon)).toBe('#880E4F'); + expect(last(result.navy)).toBe('#311B92'); + expect(last(result.olive)).toBe('#808000'); + expect(last(result.orange)).toBe('#FF9800'); + expect(last(result.purple)).toBe('#9C27B0'); + expect(last(result.red)).toBe('#F23645'); + expect(last(result.silver)).toBe('#B2B5BE'); + expect(last(result.teal)).toBe('#089981'); + expect(last(result.white)).toBe('#FFFFFF'); + expect(last(result.yellow)).toBe('#FDD835'); + }); + + // ── color.new() ───────────────────────────────────────────────── + + it('color.new() should apply transparency to hex color', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + const sourceCode = (context: any) => { + const { color } = context.pine; + + // No transparency + const no_alpha = color.new('#FF0000'); + // 0% transparency = fully opaque (alpha byte FF) + const zero_alpha = color.new('#FF0000', 0); + // 50% transparency (alpha byte 7F due to floating-point: 2.55*50=127.49...) + const half_alpha = color.new('#FF0000', 50); + // 100% transparency = fully transparent (alpha byte 00) + const full_alpha = color.new('#FF0000', 100); + + return { no_alpha, zero_alpha, half_alpha, full_alpha }; + }; + + const { result } = await pineTS.run(sourceCode); + + expect(last(result.no_alpha)).toBe('#FF0000'); + expect(last(result.zero_alpha)).toBe('#FF0000FF'); + expect(last(result.half_alpha)).toBe('#FF00007F'); + expect(last(result.full_alpha)).toBe('#FF000000'); + }); + + it('color.new() should apply transparency to named color', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + const sourceCode = (context: any) => { + const { color } = context.pine; + + const red_50 = color.new(color.red(), 50); + const white_100 = color.new(color.white(), 100); + + return { red_50, white_100 }; + }; + + const { result } = await pineTS.run(sourceCode); + + // color.red = #F23645, 50% transparency -> alpha = 127 = 0x7F (floating-point rounding) + expect(last(result.red_50)).toBe('#F236457F'); + // color.white = #FFFFFF, 100% transparency -> alpha = 0 = 0x00 + expect(last(result.white_100)).toBe('#FFFFFF00'); + }); + + it('color.new() should handle na (NaN) gracefully', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + const sourceCode = (context: any) => { + const { color, na } = context.pine; + + // color(na) is a type-cast that passes through NaN + const na_color = color.new(NaN); + + return { na_color }; + }; + + const { result } = await pineTS.run(sourceCode); + + expect(last(result.na_color)).toBeNaN(); + }); + + it('color.new() should apply transparency to rgb() string input', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + const sourceCode = (context: any) => { + const { color } = context.pine; + + // color.rgb() returns "rgb(r,g,b)" string + const rgb_input = color.rgb(207, 23, 23); + // color.new() with rgb() input should produce valid hex, not "rgba(rgb(...), alpha)" + const with_alpha = color.new(rgb_input, 85); + const with_zero = color.new(rgb_input, 0); + const no_alpha = color.new(rgb_input); + + return { rgb_input, with_alpha, with_zero, no_alpha }; + }; + + const { result } = await pineTS.run(sourceCode); + + expect(last(result.rgb_input)).toBe('rgb(207, 23, 23)'); + // 85% transparency → alpha = (100-85)/100*255 = 38.25 → 0x26 + expect(last(result.with_alpha)).toBe('#cf171726'); + // 0% transparency → fully opaque → alpha = FF + expect(last(result.with_zero)).toBe('#cf1717FF'); + // No alpha arg → return as-is + expect(last(result.no_alpha)).toBe('rgb(207, 23, 23)'); + }); + + it('color.new() should replace alpha when input already has alpha (#RRGGBBAA)', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + const sourceCode = (context: any) => { + const { color } = context.pine; + + // Simulate double color.new(): get_color() returns color.new(#00ff00, 0) = #00ff00FF + const first_pass = color.new('#00ff00', 0); // → "#00ff00FF" + // Then bgcolor does color.new(first_pass, 85) — should REPLACE alpha, not append + const second_pass = color.new(first_pass, 85); // → should be "#00ff0026", NOT "#00ff00FF26" + + // Also test with a named color double-wrap + const red_opaque = color.new(color.red(), 0); // → "#F23645FF" + const red_semi = color.new(red_opaque, 50); // → should be "#F236457F" + + return { first_pass, second_pass, red_opaque, red_semi }; + }; + + const { result } = await pineTS.run(sourceCode); + + expect(last(result.first_pass)).toBe('#00ff00FF'); + expect(last(result.second_pass)).toBe('#00ff0026'); // replaced alpha, not appended + expect(last(result.red_opaque)).toBe('#F23645FF'); + expect(last(result.red_semi)).toBe('#F236457F'); // replaced alpha, not appended + }); + + it('color.new() should apply transparency to rgba() string input', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + const sourceCode = (context: any) => { + const { color } = context.pine; + + // color.rgb() with alpha returns "rgba(r,g,b,a)" string + const rgba_input = color.rgb(100, 200, 50, 30); + // color.new() should parse rgba() and apply new alpha + const with_alpha = color.new(rgba_input, 50); + + return { rgba_input, with_alpha }; + }; + + const { result } = await pineTS.run(sourceCode); + + expect(last(result.rgba_input)).toBe('rgba(100, 200, 50, 0.7)'); + // 50% transparency → alpha = 127 → 0x7F + expect(last(result.with_alpha)).toBe('#64c8327F'); + }); + + // ── color.rgb() ───────────────────────────────────────────────── + + it('color.rgb() should create color from RGB components', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + const sourceCode = (context: any) => { + const { color } = context.pine; + + const no_alpha = color.rgb(255, 128, 0); + const with_alpha = color.rgb(255, 128, 0, 50); + + return { no_alpha, with_alpha }; + }; + + const { result } = await pineTS.run(sourceCode); + + expect(last(result.no_alpha)).toBe('rgb(255, 128, 0)'); + expect(last(result.with_alpha)).toBe('rgba(255, 128, 0, 0.5)'); + }); + + // ── color.from_gradient() ─────────────────────────────────────── + + it('color.from_gradient() should interpolate between two colors', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + const sourceCode = (context: any) => { + const { color } = context.pine; + + // At bottom = pure black + const at_bottom = color.from_gradient(0, 0, 100, '#000000', '#FFFFFF'); + // At top = pure white + const at_top = color.from_gradient(100, 0, 100, '#000000', '#FFFFFF'); + // At midpoint = gray + const at_mid = color.from_gradient(50, 0, 100, '#000000', '#FFFFFF'); + // Below bottom = clamped to bottom + const below = color.from_gradient(-10, 0, 100, '#000000', '#FFFFFF'); + // Above top = clamped to top + const above = color.from_gradient(200, 0, 100, '#000000', '#FFFFFF'); + + return { at_bottom, at_top, at_mid, below, above }; + }; + + const { result } = await pineTS.run(sourceCode); + + expect(last(result.at_bottom)).toBe('#000000'); + expect(last(result.at_top)).toBe('#FFFFFF'); + // Midpoint of #000000 and #FFFFFF = #808080 (128, 128, 128) + expect(last(result.at_mid)).toBe('#808080'); + expect(last(result.below)).toBe('#000000'); + expect(last(result.above)).toBe('#FFFFFF'); + }); + + // ── Component extraction: color.r(), color.g(), color.b(), color.t() ── + + it('color.r() should extract red component (0-255)', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + const sourceCode = (context: any) => { + const { color } = context.pine; + + const r_hex = color.r('#FF8040'); + const r_rgba = color.r('rgba(100, 200, 50, 0.5)'); + const r_na = color.r(NaN); + + return { r_hex, r_rgba, r_na }; + }; + + const { result } = await pineTS.run(sourceCode); + + expect(last(result.r_hex)).toBe(255); + expect(last(result.r_rgba)).toBe(100); + expect(last(result.r_na)).toBeNaN(); + }); + + it('color.g() should extract green component (0-255)', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + const sourceCode = (context: any) => { + const { color } = context.pine; + + const g_hex = color.g('#FF8040'); + const g_rgb = color.g('rgb(100, 200, 50)'); + + return { g_hex, g_rgb }; + }; + + const { result } = await pineTS.run(sourceCode); + + expect(last(result.g_hex)).toBe(128); + expect(last(result.g_rgb)).toBe(200); + }); + + it('color.b() should extract blue component (0-255)', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + const sourceCode = (context: any) => { + const { color } = context.pine; + + const b_hex = color.b('#FF8040'); + const b_rgb = color.b('rgb(100, 200, 50)'); + + return { b_hex, b_rgb }; + }; + + const { result } = await pineTS.run(sourceCode); + + expect(last(result.b_hex)).toBe(64); + expect(last(result.b_rgb)).toBe(50); + }); + + it('color.t() should extract transparency (0-100, Pine scale)', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + const sourceCode = (context: any) => { + const { color } = context.pine; + + // Fully opaque hex (#RRGGBB, no alpha) -> transparency = 0 + const t_opaque = color.t('#FF0000'); + // Hex with alpha byte FF (fully opaque) -> transparency = 0 + const t_hex_ff = color.t('#FF0000FF'); + // Hex with alpha byte 00 (fully transparent) -> transparency = 100 + const t_hex_00 = color.t('#FF000000'); + // rgba with alpha 0.5 -> transparency = 50 + const t_rgba = color.t('rgba(255, 0, 0, 0.5)'); + // na input -> NaN + const t_na = color.t(NaN); + + return { t_opaque, t_hex_ff, t_hex_00, t_rgba, t_na }; + }; + + const { result } = await pineTS.run(sourceCode); + + expect(last(result.t_opaque)).toBe(0); + expect(last(result.t_hex_ff)).toBe(0); + expect(last(result.t_hex_00)).toBe(100); + expect(last(result.t_rgba)).toBe(50); + expect(last(result.t_na)).toBeNaN(); + }); + + // ── color.any() — type-cast ───────────────────────────────────── + + it('color.any() should pass through values (type-cast)', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + const sourceCode = (context: any) => { + const { color } = context.pine; + + const cast_na = color.any(NaN); + const cast_hex = color.any('#FF0000'); + + return { cast_na, cast_hex }; + }; + + const { result } = await pineTS.run(sourceCode); + + // color(na) returns null (Pine Script na for colors, not NaN) + expect(last(result.cast_na)).toBeNull(); + expect(last(result.cast_hex)).toBe('#FF0000'); + }); + + // ── Roundtrip: create color then extract components ───────────── + + it('should roundtrip: rgb() -> r/g/b extraction', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + const sourceCode = (context: any) => { + const { color } = context.pine; + + const c = color.rgb(100, 150, 200); + const r = color.r(c); + const g = color.g(c); + const b = color.b(c); + const t = color.t(c); + + return { r, g, b, t }; + }; + + const { result } = await pineTS.run(sourceCode); + + expect(last(result.r)).toBe(100); + expect(last(result.g)).toBe(150); + expect(last(result.b)).toBe(200); + expect(last(result.t)).toBe(0); // No transparency + }); + + it('should roundtrip: from_gradient() -> component extraction', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, sDate, eDate); + + const sourceCode = (context: any) => { + const { color } = context.pine; + + // Gradient from red to blue at midpoint + const c = color.from_gradient(50, 0, 100, '#FF0000', '#0000FF'); + const r = color.r(c); + const g = color.g(c); + const b = color.b(c); + + return { r, g, b }; + }; + + const { result } = await pineTS.run(sourceCode); + + // Midpoint between red(255,0,0) and blue(0,0,255) = (128,0,128) + expect(last(result.r)).toBe(128); + expect(last(result.g)).toBe(0); + expect(last(result.b)).toBe(128); + }); +}); diff --git a/tests/namespaces/fill/fill.test.ts b/tests/namespaces/fill/fill.test.ts index fc421bb..4063541 100644 --- a/tests/namespaces/fill/fill.test.ts +++ b/tests/namespaces/fill/fill.test.ts @@ -152,6 +152,59 @@ describe('FILL Namespace', () => { expect(fillEntry.options.color).toBe('#ff000080'); }); + it('fill() always stores per-bar color data for dynamic colors', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, new Date('2025-01-01').getTime(), new Date('2025-01-10').getTime()); + + const { plots } = await pineTS.run((context) => { + var p1 = plot(close, 'Upper'); + var p2 = plot(open, 'Lower'); + // Dynamic color: flips green/red based on close > open + fill(p1, p2, close > open ? color.green : color.red); + return {}; + }); + + const fillEntry = Object.values(plots).find((p: any) => p.options?.style === 'fill') as any; + expect(fillEntry).toBeDefined(); + + // Fill should have per-bar data with color in options + expect(fillEntry.data).toBeDefined(); + expect(fillEntry.data.length).toBeGreaterThan(0); + + // Each data entry should have an options.color + for (const d of fillEntry.data) { + expect(d.options).toBeDefined(); + expect(d.options.color).toBeDefined(); + expect(typeof d.options.color).toBe('string'); + } + + // Should contain both green and red entries (since some bars are bullish, some bearish) + const uniqueColors = new Set(fillEntry.data.map((d: any) => d.options.color)); + expect(uniqueColors.size).toBeGreaterThanOrEqual(1); + }); + + it('fill() with static color still stores per-bar data', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, new Date('2025-01-01').getTime(), new Date('2025-01-10').getTime()); + + const { plots } = await pineTS.run((context) => { + var p1 = plot(close, 'Top'); + var p2 = plot(open, 'Bottom'); + fill(p1, p2, color.new(color.blue, 80)); + return {}; + }); + + const fillEntry = Object.values(plots).find((p: any) => p.options?.style === 'fill') as any; + expect(fillEntry).toBeDefined(); + + // Even static colors should have per-bar data (always pushed now) + expect(fillEntry.data).toBeDefined(); + expect(fillEntry.data.length).toBeGreaterThan(0); + + // All entries should have the same color + const colors = fillEntry.data.map((d: any) => d.options.color); + const uniqueColors = new Set(colors); + expect(uniqueColors.size).toBe(1); + }); + it('fill() with title uses title-based key', async () => { const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'D', null, new Date('2025-01-01').getTime(), new Date('2025-01-10').getTime()); diff --git a/tests/namespaces/str.test.ts b/tests/namespaces/str.test.ts index 11e7bf6..1b81f49 100644 --- a/tests/namespaces/str.test.ts +++ b/tests/namespaces/str.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import PineTS from '../../src/PineTS.class'; import { Provider } from '../../src/marketData/Provider.class'; +import { PineArrayObject } from '../../src/namespaces/array/PineArrayObject'; describe('Str Namespace', () => { it('should handle all string operations correctly', async () => { @@ -99,13 +100,10 @@ describe('Str Namespace', () => { expect(last(result.rep_occ)).toBe('a b c b a'); expect(last(result.sub)).toBe('ell'); - // Split returns an array, but wrapped in Series/array structure? - // In PineTS, arrays are usually returned as is if they are not series of arrays? - // Let's check what split returns. In Str.ts: String(source).split(separator) -> string[] - // The runtime might wrap this or treat it as a value. - // If it's a value in the context.result, it might be stored as an array of arrays if it's per bar. + // str.split returns a PineArrayObject (array) per Pine Script spec const splitRes = last(result.spl); - expect(splitRes).toEqual(['a', 'b', 'c']); + expect(splitRes).toBeInstanceOf(PineArrayObject); + expect(splitRes.array).toEqual(['a', 'b', 'c']); expect(last(result.has)).toBe(true); expect(last(result.starts)).toBe(true); diff --git a/tests/transpiler/function-param-named-args.test.ts b/tests/transpiler/function-param-named-args.test.ts new file mode 100644 index 0000000..5a3185a --- /dev/null +++ b/tests/transpiler/function-param-named-args.test.ts @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2025 Alaa-eddine KADDOURI + +/** + * Function Parameter in Named Arguments (ObjectExpression) Tests + * + * Regression tests for a bug where function parameters used as values in + * named argument objects were incorrectly scoped. + * + * Example of the bug: + * function draw(col) { + * line.new(x1, y1, x2, y2, {color: col}) + * } + * + * Was transpiled to: {color: $.let.col} ← WRONG ($.let.col is undefined) + * Should be: {color: col} ← CORRECT (raw function parameter) + * + * Root cause: The ObjectExpression handler in ExpressionTransformer.ts only + * checked isContextBound() for raw identifier preservation. Non-root function + * parameters are registered as localSeriesVars (not contextBoundVars), so they + * fell through to createScopedVariableReference() which resolved them to $.let.col. + * + * Fix: Also check isLocalSeriesVar() and isLoopVariable() in the ObjectExpression + * property value handler. + */ + +import { describe, it, expect } from 'vitest'; +import { transpile } from '../../src/transpiler/index'; +import { PineTS } from '../../src/PineTS.class'; +import { Provider } from '../../src/marketData/Provider.class'; + +function makePineTS() { + return new PineTS(Provider.Mock, 'BTCUSDC', '1h', 10, + new Date('2024-01-01').getTime()); +} + +describe('Function Parameters in Named Args (ObjectExpression)', () => { + it('should use raw parameter name in named args, not $.let.param', () => { + const code = ` +//@version=5 +indicator("Param Named Args Test", overlay=true) + +draw(col) => + line.new(bar_index - 1, close, bar_index, close, color=col) + +draw(color.red) +`; + const result = transpile(code); + const jsCode = result.toString(); + + // Inside the draw function, {color: col} should use raw 'col', not '$.let.col' + // The named args object should be: {color: col} + expect(jsCode).not.toContain('$.let.col'); + expect(jsCode).not.toContain('$.var.col'); + + // Should contain raw 'col' in the object literal + expect(jsCode).toMatch(/\{\s*color:\s*col\s*\}/); + }); + + it('should handle multiple function params in named args', () => { + const code = ` +//@version=5 +indicator("Multi Param Test", overlay=true) + +drawLine(col, w) => + line.new(bar_index - 1, close, bar_index, close, color=col, width=w) + +drawLine(color.blue, 2) +`; + const result = transpile(code); + const jsCode = result.toString(); + + // Both col and w should be raw identifiers + expect(jsCode).not.toContain('$.let.col'); + expect(jsCode).not.toContain('$.let.w'); + }); + + it('should pass color through function parameter to line.new (runtime)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=5 +indicator("Color Param Runtime", overlay=true) + +draw(col) => + line.new(bar_index - 1, close, bar_index, close, color=col) + +if barstate.islast + draw(color.red) +`; + const { plots } = await pineTS.run(code); + const lines = plots['__lines__']?.data?.[0]?.value || []; + + // Should have at least one line + expect(lines.length).toBeGreaterThanOrEqual(1); + + // The line created on the last bar should have the red color + const activeLine = lines.find((l: any) => l.x1 !== null); + expect(activeLine).toBeDefined(); + expect(activeLine.color).toBe('#F23645'); + }); + + it('should pass color through nested function calls (runtime)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=5 +indicator("Nested Color Runtime", overlay=true) + +inner(col) => + line.new(bar_index - 1, close, bar_index, close, color=col) + +outer(col) => + inner(col) + +if barstate.islast + outer(color.blue) +`; + const { plots } = await pineTS.run(code); + const lines = plots['__lines__']?.data?.[0]?.value || []; + + const activeLine = lines.find((l: any) => l.x1 !== null); + expect(activeLine).toBeDefined(); + expect(activeLine.color).toBe('#2196F3'); + }); + + it('should pass label textcolor through function parameter (runtime)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=5 +indicator("Label Color Runtime", overlay=true) + +drawLabel(col) => + label.new(bar_index, close, "test", textcolor=col) + +if barstate.islast + drawLabel(color.white) +`; + const { plots } = await pineTS.run(code); + const labels = plots['__labels__']?.data?.[0]?.value || []; + + const activeLabel = labels.find((l: any) => l.x !== null); + expect(activeLabel).toBeDefined(); + expect(activeLabel.textcolor).toBe('#FFFFFF'); + }); +}); diff --git a/tests/transpiler/function-scope-property-access.test.ts b/tests/transpiler/function-scope-property-access.test.ts new file mode 100644 index 0000000..7214b9d --- /dev/null +++ b/tests/transpiler/function-scope-property-access.test.ts @@ -0,0 +1,434 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2025 Alaa-eddine KADDOURI + +import { describe, it, expect } from 'vitest'; +import { PineTS, Provider } from 'index'; +import { transpile } from '../../src/transpiler/index'; + +/** + * Tests for correct scope resolution when property access on function-scoped + * variables is used as an argument to namespace methods. + * + * BUG: In `transformFunctionArgument()`, the `isPropertyAccess` branch used + * `ASTFactory.createContextVariableReference(kind, varName)` which always + * produces `$.let.varName` (global scope). For variables inside functions, + * it should use `createScopedVariableReference(name, scopeManager)` which + * checks `isVariableInFunctionScope()` and produces `$$.let.varName` + * (function-local context via `$.peekCtx()`). + * + * PATTERN: This bug manifests when: + * 1. A UDT variable is declared inside a function (or loop inside a function) + * 2. A property of that variable is passed as an argument to a namespace + * method call (e.g., `line.delete(oldZone.zoneLine)`) + * 3. The transpiler hoists the argument into a `line.param()` call + * 4. The hoisted argument incorrectly references `$.let.varName` instead + * of `$$.let.varName` + * + * REGRESSION HISTORY: + * This was a pre-existing bug discovered via pressure-zone-analyzer.pine + * (2026-03-11). The `cleanupZones` function has a while loop that calls + * `line.delete(oldZone.zoneLine)` — the `oldZone` variable was resolved + * to global scope, causing "Cannot read properties of undefined" at runtime. + */ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makePineTS = () => + new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2019-01-01').getTime(), new Date('2019-03-01').getTime()); + +/** Transpile Pine Script source and return the generated code string */ +function transpileToString(pineCode: string): string { + const fn = transpile(pineCode); + return fn.toString(); +} + +// =========================================================================== +// A) Transpiler output tests — verify $$ is used for function-scoped vars +// =========================================================================== + +describe('Function-scope property access in namespace args — transpiler output', () => { + + // ----------------------------------------------------------------------- + // Function parameters: stay as raw identifiers, property access uses + // raw param name (not $.let or $$.let). The key check is that the + // hoisted line.param() uses the raw parameter, NOT a $.let reference. + // ----------------------------------------------------------------------- + + it('function parameter property access stays as raw identifier', () => { + const code = ` +//@version=5 +indicator("fn param prop access") + +type MyZone + line zoneLine + +deleteZoneLine(MyZone zone) => + line.delete(zone.zoneLine) + +plot(close) +`; + const output = transpileToString(code); + // Function params are NOT renamed — zone.zoneLine stays as raw identifier + expect(output).toMatch(/zone\.zoneLine/); + // Must NOT be wrapped in $.let or $$.let + expect(output).not.toMatch(/\$\.let\.\w*zone/); + expect(output).not.toMatch(/\$\$\.let\.\w*zone/); + }); + + // ----------------------------------------------------------------------- + // Locally declared variables inside loops within functions: these ARE + // renamed (e.g., whl1_oldZone) and must use $$ (function-local context), + // NOT $ (global context). This is the exact bug from pressure-zone-analyzer. + // ----------------------------------------------------------------------- + + it('while-loop variable property access inside function uses $$ (pressure-zone-analyzer repro)', () => { + const code = ` +//@version=5 +indicator("while loop fn scope") + +type PressureZone + line zoneLine + +cleanupZones(array zones, int maxZones) => + while array.size(zones) > maxZones + PressureZone oldZone = array.shift(zones) + line.delete(oldZone.zoneLine) + +plot(close) +`; + const output = transpileToString(code); + // oldZone is inside a while loop inside a function — must use $$ + expect(output).toMatch(/\$\$\.let\.\w*oldZone/); + // Must NOT have bare $.let.oldZone (without preceding $) + // Use negative lookbehind to exclude $$.let matches + expect(output).not.toMatch(/(? { + const code = ` +//@version=5 +indicator("for loop fn scope") + +type Wave + line waveLine + +clearWaves(array waves) => + for i = 0 to array.size(waves) - 1 + Wave w = array.get(waves, i) + line.delete(w.waveLine) + +plot(close) +`; + const output = transpileToString(code); + // w is inside a for loop inside a function — must use $$ + expect(output).toMatch(/\$\$\.let\.\w*w\b/); + expect(output).not.toMatch(/(? { + const code = ` +//@version=5 +indicator("if block fn scope") + +type DrawObj + label lbl + +removeIfExists(array arr) => + if array.size(arr) > 0 + DrawObj item = array.shift(arr) + label.delete(item.lbl) + +plot(close) +`; + const output = transpileToString(code); + // item is inside an if block inside a function — must use $$ + expect(output).toMatch(/\$\$\.let\.\w*item/); + expect(output).not.toMatch(/(? { + const code = ` +//@version=5 +indicator("multi prop while fn") + +type Zone + line topLine + line botLine + label lbl + +cleanupZones(array arr) => + while array.size(arr) > 3 + Zone old = array.shift(arr) + line.delete(old.topLine) + line.delete(old.botLine) + label.delete(old.lbl) + +plot(close) +`; + const output = transpileToString(code); + // All references to old inside the function must use $$ + expect(output).toMatch(/\$\$\.let\.\w*old\b/); + expect(output).not.toMatch(/(? { + const code = ` +//@version=5 +indicator("global scope prop") + +type MyZone + line zoneLine + +var MyZone zone = MyZone.new(line.new(na, na, na, na)) +line.delete(zone.zoneLine) + +plot(close) +`; + const output = transpileToString(code); + // At global scope, zone should use $ (no $$ exists) + expect(output).toMatch(/\$\.(?:var|let)\.\w*zone/); + }); +}); + + +// =========================================================================== +// B) Runtime tests — verify no crash with function-scoped UDT property access +// =========================================================================== + +describe('Function-scope property access in namespace args — runtime behavior', () => { + + it('line.delete(udt.field) inside function does not crash', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=5 +indicator("fn line delete", overlay=true) + +type MyZone + line zoneLine + float price + +var myZones = array.new(0) + +addZone(array zones, float p) => + zones.push(MyZone.new(line.new(bar_index - 5, p, bar_index, p), p)) + +removeOldest(array zones) => + if array.size(zones) > 0 + MyZone old = array.shift(zones) + line.delete(old.zoneLine) + +addZone(myZones, close) +if array.size(myZones) > 3 + removeOldest(myZones) + +plot(array.size(myZones), "count") +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + expect(plots['count']).toBeDefined(); + // Count should be capped at 3 (add one per bar, remove when > 3) + const lastVal = plots['count'].data[plots['count'].data.length - 1].value; + expect(lastVal).toBeLessThanOrEqual(4); + }); + + it('while loop cleanup pattern (pressure-zone-analyzer repro)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=5 +indicator("while cleanup", overlay=true) + +type PressureZone + line zoneLine + float price + +var zones = array.new(0) + +cleanupZones(array zoneArr, int maxZones) => + while array.size(zoneArr) > maxZones + PressureZone oldZone = array.shift(zoneArr) + line.delete(oldZone.zoneLine) + +zones.push(PressureZone.new(line.new(bar_index - 3, close, bar_index, close, color=#ff0000), close)) +cleanupZones(zones, 3) + +plot(array.size(zones), "size") +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + expect(plots['size']).toBeDefined(); + const lastVal = plots['size'].data[plots['size'].data.length - 1].value; + expect(lastVal).toBeLessThanOrEqual(3); + }); + + it('multiple drawing field deletions inside function', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=5 +indicator("multi field delete", overlay=true) + +type AnnotatedZone + line topLine + line botLine + label lbl + +var zones = array.new(0) + +removeZone(array arr) => + if array.size(arr) > 0 + AnnotatedZone z = array.shift(arr) + line.delete(z.topLine) + line.delete(z.botLine) + label.delete(z.lbl) + +if barstate.isfirst + zones.push(AnnotatedZone.new( + line.new(0, 100, 10, 100, color=#ff0000), + line.new(0, 90, 10, 90, color=#00ff00), + label.new(5, 95, "Zone1") + )) + zones.push(AnnotatedZone.new( + line.new(0, 80, 10, 80, color=#ff0000), + line.new(0, 70, 10, 70, color=#00ff00), + label.new(5, 75, "Zone2") + )) + +if bar_index == 1 + removeZone(zones) + +plot(array.size(zones), "remaining") +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + expect(plots['remaining']).toBeDefined(); + }); + + it('for loop with UDT property access inside function', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=5 +indicator("for loop fn prop", overlay=true) + +type Wave + line waveLine + float price + +var waves = array.new(0) + +clearAllWaves(array arr) => + for i = 0 to array.size(arr) - 1 + Wave w = array.get(arr, i) + line.delete(w.waveLine) + array.clear(arr) + +if barstate.isfirst + waves.push(Wave.new(line.new(0, 100, 5, 200, color=#ff0000), 100.0)) + waves.push(Wave.new(line.new(0, 200, 5, 300, color=#00ff00), 200.0)) + +if bar_index == 2 + clearAllWaves(waves) + +plot(array.size(waves), "count") +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + expect(plots['count']).toBeDefined(); + }); + + it('label.set_text with UDT property inside function', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=5 +indicator("label set in fn", overlay=true) + +type Marker + label lbl + string text + +updateMarker(Marker m, string newText) => + label.set_text(m.lbl, newText) + label.set_xy(m.lbl, bar_index, close) + +var Marker marker = Marker.new(label.new(0, 0, "init"), "init") +updateMarker(marker, "Bar " + str.tostring(bar_index)) + +plot(close, "price") +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + expect(plots['__labels__']).toBeDefined(); + }); + + it('nested function calls with UDT property access', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=5 +indicator("nested fn prop", overlay=true) + +type Channel + line upper + line lower + +deleteChannel(Channel ch) => + line.delete(ch.upper) + line.delete(ch.lower) + +manageChannels(array channels, int max) => + while array.size(channels) > max + Channel old = array.shift(channels) + deleteChannel(old) + +var channels = array.new(0) +channels.push(Channel.new( + line.new(bar_index - 3, high, bar_index, high, color=#ff0000), + line.new(bar_index - 3, low, bar_index, low, color=#00ff00) +)) +manageChannels(channels, 2) + +plot(array.size(channels), "ch_count") +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + expect(plots['ch_count']).toBeDefined(); + const lastVal = plots['ch_count'].data[plots['ch_count'].data.length - 1].value; + expect(lastVal).toBeLessThanOrEqual(2); + }); + + it('function with request.security context does not crash', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=5 +indicator("fn prop with security", overlay=true) + +type Level + line ln + float val + +var levels = array.new(0) + +cleanLevels(array arr, int maxLevels) => + while array.size(arr) > maxLevels + Level old = array.shift(arr) + line.delete(old.ln) + +levels.push(Level.new(line.new(bar_index - 2, close, bar_index, close), close)) +cleanLevels(levels, 3) + +htfClose = request.security(syminfo.tickerid, "M", close) +plot(htfClose, "htf") +plot(array.size(levels), "lvl_count") +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + expect(plots['lvl_count']).toBeDefined(); + }); +}); diff --git a/tests/transpiler/generic-type-strong-typing.test.ts b/tests/transpiler/generic-type-strong-typing.test.ts new file mode 100644 index 0000000..768ebda --- /dev/null +++ b/tests/transpiler/generic-type-strong-typing.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect } from 'vitest'; +import { PineTS, Provider } from 'index'; + +/** + * Tests for generic type strong typing: array.new() + * + * The transpiler rewrites array.new(...) → array.new_float(...) for known types. + * This ensures the array gets the correct element type even when the initial value + * would be inferred as a different type (e.g. 0.0 → int in JS). + * + * Matrix generic syntax (matrix.new) is also rewritten to matrix.new_TYPE + * but only as a transpiler compatibility shim — no runtime type enforcement exists + * for matrix yet. Those tests verify the transpiler rewrite doesn't crash. + */ +describe('Generic Type Strong Typing', () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2019-01-01').getTime(), new Date('2019-02-01').getTime()); + + // ─── Array ─────────────────────────────────────────────────────── + + describe('array.new strong typing', () => { + it('array.new allows setting float values (the original bug)', async () => { + // This was the core bug: array.new(3, 0.0) inferred type as int + // because 0.0 === 0 in JS, then array.set(arr, 0, 2.1) failed + const code = ` +//@version=6 +indicator("array.new strong typing") +var arr = array.new(3, 0.0) +array.set(arr, 0, 2.1) +array.set(arr, 1, 3.5) +array.set(arr, 2, 0.7) +plot(array.get(arr, 0), "v0") +plot(array.get(arr, 1), "v1") +plot(array.get(arr, 2), "v2") +`; + const { plots } = await pineTS.run(code); + + expect(plots['v0'].data[0].value).toBe(2.1); + expect(plots['v1'].data[0].value).toBe(3.5); + expect(plots['v2'].data[0].value).toBe(0.7); + }); + + it('array.new with integer initial value accepts floats', async () => { + // Even with integer 0 as initial value, forces float type + const code = ` +//@version=6 +indicator("array.new int initial") +var arr = array.new(2, 0) +array.set(arr, 0, 1.5) +array.set(arr, 1, 2.7) +plot(array.get(arr, 0), "v0") +plot(array.get(arr, 1), "v1") +`; + const { plots } = await pineTS.run(code); + + expect(plots['v0'].data[0].value).toBe(1.5); + expect(plots['v1'].data[0].value).toBe(2.7); + }); + + it('array.new creates integer-typed array', async () => { + const code = ` +//@version=6 +indicator("array.new typing") +var arr = array.new(3, 0) +array.set(arr, 0, 10) +array.set(arr, 1, 20) +array.set(arr, 2, 30) +plot(array.get(arr, 0), "v0") +plot(array.get(arr, 1), "v1") +plot(array.get(arr, 2), "v2") +`; + const { plots } = await pineTS.run(code); + + expect(plots['v0'].data[0].value).toBe(10); + expect(plots['v1'].data[0].value).toBe(20); + expect(plots['v2'].data[0].value).toBe(30); + }); + + it('array.new creates string-typed array', async () => { + const code = ` +//@version=6 +indicator("array.new typing") +var arr = array.new(2, "") +array.set(arr, 0, "hello") +array.set(arr, 1, "world") +plot(array.size(arr), "size") +`; + const { plots } = await pineTS.run(code); + + expect(plots['size'].data[0].value).toBe(2); + }); + + it('array.new creates boolean-typed array', async () => { + const code = ` +//@version=6 +indicator("array.new typing") +var arr = array.new(3, false) +array.set(arr, 0, true) +array.set(arr, 1, false) +array.set(arr, 2, true) +plot(array.size(arr), "size") +`; + const { plots } = await pineTS.run(code); + + expect(plots['size'].data[0].value).toBe(3); + }); + + it('array.new creates color-typed array', async () => { + const code = ` +//@version=6 +indicator("array.new typing") +var arr = array.new(2, color.red) +array.set(arr, 0, color.blue) +array.set(arr, 1, color.green) +plot(array.size(arr), "size") +`; + const { plots } = await pineTS.run(code); + + expect(plots['size'].data[0].value).toBe(2); + }); + + it('array.new with no initial value', async () => { + // array.new(3) — no initial value, should still be float-typed + const code = ` +//@version=6 +indicator("array.new no init") +var arr = array.new(3) +array.set(arr, 0, 1.1) +array.set(arr, 1, 2.2) +array.set(arr, 2, 3.3) +plot(array.get(arr, 0), "v0") +plot(array.get(arr, 1), "v1") +plot(array.get(arr, 2), "v2") +`; + const { plots } = await pineTS.run(code); + + expect(plots['v0'].data[0].value).toBeCloseTo(1.1); + expect(plots['v1'].data[0].value).toBeCloseTo(2.2); + expect(plots['v2'].data[0].value).toBeCloseTo(3.3); + }); + + it('array.new used with array operations (push, avg, sum)', async () => { + const code = ` +//@version=6 +indicator("array.new operations") +var arr = array.new(0) +if barstate.isfirst + array.push(arr, 1.5) + array.push(arr, 2.5) + array.push(arr, 3.0) +plot(array.size(arr), "size") +plot(array.avg(arr), "avg") +plot(array.sum(arr), "sum") +`; + const { plots } = await pineTS.run(code); + + expect(plots['size'].data[0].value).toBe(3); + expect(plots['avg'].data[0].value).toBeCloseTo(2.333, 2); + expect(plots['sum'].data[0].value).toBe(7); + }); + }); + + // ─── Matrix ────────────────────────────────────────────────────── + + describe('matrix.new transpiler rewrite (no runtime type enforcement)', () => { + it('matrix.new transpiles and runs without error', async () => { + const code = ` +//@version=6 +indicator("matrix.new strong typing") +var m = matrix.new(2, 2, 0) +matrix.set(m, 0, 0, 1.5) +matrix.set(m, 0, 1, 2.7) +matrix.set(m, 1, 0, 3.3) +matrix.set(m, 1, 1, 4.9) +plot(matrix.get(m, 0, 0), "m00") +plot(matrix.get(m, 0, 1), "m01") +plot(matrix.get(m, 1, 0), "m10") +plot(matrix.get(m, 1, 1), "m11") +`; + const { plots } = await pineTS.run(code); + + expect(plots['m00'].data[0].value).toBe(1.5); + expect(plots['m01'].data[0].value).toBe(2.7); + expect(plots['m10'].data[0].value).toBe(3.3); + expect(plots['m11'].data[0].value).toBe(4.9); + }); + + it('matrix.new transpiles and runs without error', async () => { + const code = ` +//@version=6 +indicator("matrix.new typing") +var m = matrix.new(2, 2, 0) +matrix.set(m, 0, 0, 1) +matrix.set(m, 0, 1, 2) +matrix.set(m, 1, 0, 3) +matrix.set(m, 1, 1, 4) +plot(matrix.get(m, 0, 0), "m00") +plot(matrix.rows(m), "rows") +plot(matrix.columns(m), "cols") +`; + const { plots } = await pineTS.run(code); + + expect(plots['m00'].data[0].value).toBe(1); + expect(plots['rows'].data[0].value).toBe(2); + expect(plots['cols'].data[0].value).toBe(2); + }); + + it('matrix.new works with matrix operations', async () => { + const code = ` +//@version=6 +indicator("matrix.new operations") +var m = matrix.new(2, 2, 0) +matrix.set(m, 0, 0, 1.5) +matrix.set(m, 0, 1, 2.5) +matrix.set(m, 1, 0, 3.5) +matrix.set(m, 1, 1, 4.5) +plot(matrix.avg(m), "avg") +plot(matrix.max(m), "max") +plot(matrix.min(m), "min") +plot(matrix.elements_count(m), "count") +`; + const { plots } = await pineTS.run(code); + + expect(plots['avg'].data[0].value).toBe(3); + expect(plots['max'].data[0].value).toBe(4.5); + expect(plots['min'].data[0].value).toBe(1.5); + expect(plots['count'].data[0].value).toBe(4); + }); + }); + + // ─── Edge Cases ────────────────────────────────────────────────── + + describe('Generic type edge cases', () => { + it('array.new and array.new coexist in same script', async () => { + const code = ` +//@version=6 +indicator("mixed generic types") +var floats = array.new(2, 0) +var ints = array.new(2, 0) +array.set(floats, 0, 1.5) +array.set(floats, 1, 2.5) +array.set(ints, 0, 10) +array.set(ints, 1, 20) +plot(array.get(floats, 0), "f0") +plot(array.get(floats, 1), "f1") +plot(array.get(ints, 0), "i0") +plot(array.get(ints, 1), "i1") +`; + const { plots } = await pineTS.run(code); + + expect(plots['f0'].data[0].value).toBe(1.5); + expect(plots['f1'].data[0].value).toBe(2.5); + expect(plots['i0'].data[0].value).toBe(10); + expect(plots['i1'].data[0].value).toBe(20); + }); + + it('generic type inside user function', async () => { + const code = ` +//@version=6 +indicator("generic type in function") +make_array() => + arr = array.new(3, 0) + array.set(arr, 0, 1.1) + array.set(arr, 1, 2.2) + array.set(arr, 2, 3.3) + arr + +var result = make_array() +plot(array.get(result, 0), "v0") +plot(array.get(result, 1), "v1") +plot(array.get(result, 2), "v2") +`; + const { plots } = await pineTS.run(code); + + expect(plots['v0'].data[0].value).toBeCloseTo(1.1); + expect(plots['v1'].data[0].value).toBeCloseTo(2.2); + expect(plots['v2'].data[0].value).toBeCloseTo(3.3); + }); + + it('mixed array and matrix generic types', async () => { + const code = ` +//@version=6 +indicator("mixed array and matrix generics") +var arr = array.new(3, 0) +var m = matrix.new(2, 2, 0) +array.set(arr, 0, 1.5) +matrix.set(m, 0, 0, 2.5) +plot(array.get(arr, 0), "arr_val") +plot(matrix.get(m, 0, 0), "mat_val") +`; + const { plots } = await pineTS.run(code); + + expect(plots['arr_val'].data[0].value).toBe(1.5); + expect(plots['mat_val'].data[0].value).toBe(2.5); + }); + }); +}); diff --git a/tests/transpiler/loop-series-wrapping.test.ts b/tests/transpiler/loop-series-wrapping.test.ts new file mode 100644 index 0000000..e1ba13b --- /dev/null +++ b/tests/transpiler/loop-series-wrapping.test.ts @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2025 Alaa-eddine KADDOURI + +/** + * Loop Series Variable $.get() Wrapping Tests + * + * Regression tests for three related bugs where Series variables (bar_index, + * close, etc.) were not wrapped with $.get() in loop expressions, causing: + * + * BUG 1 - For-loop UPDATE expression: + * Pine: for i = 0 to bar_index + * Transpiled update: 0 <= bar_index ? i++ : i-- + * Bug: raw Series `bar_index` → NaN comparison → always picks i-- → infinite loop + * Fix: Added addArrayAccess() + MemberExpression/CallExpression handlers to + * the update expression walker in StatementTransformer.ts + * + * BUG 2 - For-loop INIT expression: + * Pine: for i = bar_index to 0 + * Transpiled init: let i = bar_index + * Bug: raw Series assigned to i → object, not a number → loop body never executes + * Fix: Added addArrayAccess() + namespace check to init expression walker + * + * BUG 3 - While-loop TEST condition: + * Pine: while bar_index > cnt + * Transpiled: while (bar_index > $.get(cnt, 0)) + * Bug: raw Series `bar_index` → NaN comparison → always false → loop never executes + * Fix: Added addArrayAccess() + namespace check to while test walker + */ + +import { describe, it, expect } from 'vitest'; +import { transpile } from '../../src/transpiler/index'; +import { PineTS } from '../../src/PineTS.class'; +import { Provider } from '../../src/marketData/Provider.class'; + +function makePineTS() { + return new PineTS(Provider.Mock, 'BTCUSDC', '1h', 10, + new Date('2024-01-01').getTime()); +} + +describe('For Loop: Series in UPDATE expression (infinite loop bug)', () => { + it('should wrap bar_index with $.get() in for-loop update ternary', () => { + const code = ` +//@version=5 +indicator("For Update Test") +float sum = 0.0 +for i = 0 to bar_index + sum += 1 +plot(sum) +`; + const result = transpile(code); + const jsCode = result.toString(); + + // The update expression's ternary condition should use $.get(bar_index, 0), + // not raw bar_index. Raw bar_index is a Series object which causes + // NaN comparisons → always picks i-- → infinite loop. + // Look for the for-loop update: the ternary `0 <= $.get(bar_index, 0) ? i++ : i--` + expect(jsCode).not.toMatch(/0\s*<=\s*bar_index\s*\?/); + }); + + it('should not infinite-loop when bar_index is used as upper bound (runtime)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=5 +indicator("For Update Runtime") +float sum = 0.0 +for i = 0 to bar_index + sum += 1 +plot(sum, "Sum") +`; + // This would TIMEOUT (infinite loop) if the bug is present + const resultPromise = pineTS.run(code); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('TIMEOUT: infinite loop detected')), 10000) + ); + const { plots } = await Promise.race([resultPromise, timeoutPromise]) as any; + + expect(plots['Sum']).toBeDefined(); + // On bar 9 (last of 10 bars), sum = bar_index + 1 = 10 + const lastValue = plots['Sum'].data[plots['Sum'].data.length - 1].value; + expect(lastValue).toBe(10); + }); + + it('should wrap close with $.get() in for-loop update when used as bound', () => { + const code = ` +//@version=5 +indicator("For Update Close Test") +int cnt = 0 +for i = 0 to close + cnt += 1 + if cnt > 200 + break +plot(cnt) +`; + const result = transpile(code); + const jsCode = result.toString(); + + // The update ternary should not contain raw 'close' as a comparison operand + // It should be $.get(close, 0) + expect(jsCode).not.toMatch(/0\s*<=\s*close\s*\?/); + }); +}); + +describe('For Loop: Series in INIT expression (loop never runs bug)', () => { + it('should wrap bar_index with $.get() in for-loop init', () => { + const code = ` +//@version=5 +indicator("For Init Test") +float sum = 0.0 +for i = bar_index to 0 + sum += 1 +plot(sum) +`; + const result = transpile(code); + const jsCode = result.toString(); + + // The init expression should use $.get(bar_index, 0), not raw bar_index. + // Raw bar_index is a Series object → assigned to i → loop condition + // compares object to number → NaN → loop never runs. + // Look for: let i = $.get(bar_index, 0) (not: let i = bar_index) + expect(jsCode).not.toMatch(/let\s+\w+\s*=\s*bar_index\s*[;,]/); + }); + + it('should run loop body when bar_index is start value (runtime)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=5 +indicator("For Init Runtime") +float sum = 0.0 +for i = bar_index to 0 + sum += 1 +plot(sum, "Sum") +`; + const { plots } = await pineTS.run(code); + expect(plots['Sum']).toBeDefined(); + + // On bar 9 (last of 10 bars), iterates from 9 down to 0 = 10 iterations + const lastValue = plots['Sum'].data[plots['Sum'].data.length - 1].value; + expect(lastValue).toBe(10); + }); + + it('should handle expression in init: bar_index - 5 (runtime)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=5 +indicator("For Init Expr Runtime") +float sum = 0.0 +for i = bar_index - 2 to bar_index + sum += 1 +plot(sum, "Sum") +`; + const { plots } = await pineTS.run(code); + expect(plots['Sum']).toBeDefined(); + + // On any bar where bar_index >= 2: iterates from (bar_index-2) to bar_index = 3 iterations + const lastValue = plots['Sum'].data[plots['Sum'].data.length - 1].value; + expect(lastValue).toBe(3); + }); +}); + +describe('While Loop: Series in TEST condition (loop never runs bug)', () => { + it('should wrap bar_index with $.get() in while-loop condition', () => { + const code = ` +//@version=5 +indicator("While Test") +int cnt = 0 +while bar_index > cnt + cnt += 1 +plot(cnt) +`; + const result = transpile(code); + const jsCode = result.toString(); + + // The while condition should use $.get(bar_index, 0), not raw bar_index. + // Raw bar_index is a Series → NaN comparison → loop never runs. + expect(jsCode).not.toMatch(/while\s*\(\s*bar_index\s*>/); + }); + + it('should execute while loop when bar_index is in condition (runtime)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=5 +indicator("While Runtime") +int cnt = 0 +while bar_index > cnt + cnt += 1 +plot(cnt, "Count") +`; + const { plots } = await pineTS.run(code); + expect(plots['Count']).toBeDefined(); + + // On bar 9: while 9 > cnt → cnt counts up to 9 + const lastValue = plots['Count'].data[plots['Count'].data.length - 1].value; + expect(lastValue).toBe(9); + }); + + it('should handle close in while condition (runtime)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=5 +indicator("While Close Runtime") +float val = 0.0 +while val < close and val < 10000 + val += 1000 +plot(val, "Val") +`; + const { plots } = await pineTS.run(code); + expect(plots['Val']).toBeDefined(); + + // val increments by 1000 until >= close or >= 10000 + // Should be > 0 (loop actually ran) + const lastValue = plots['Val'].data[plots['Val'].data.length - 1].value; + expect(lastValue).toBeGreaterThan(0); + }); +}); diff --git a/tests/transpiler/member-expression-in-operands.test.ts b/tests/transpiler/member-expression-in-operands.test.ts index 63c4f5a..6d33660 100644 --- a/tests/transpiler/member-expression-in-operands.test.ts +++ b/tests/transpiler/member-expression-in-operands.test.ts @@ -123,6 +123,94 @@ plot(t.perf, "perf") expect(hasFiniteValue).toBe(true); }); + it('complex index expression in bracket access (BinaryExpression)', async () => { + // Tests that close[strideInput * 2] correctly transforms the BinaryExpression index + // so identifiers inside the index get properly scoped ($.get($.let.glb1_strideInput, 0) * 2) + const code = ` +//@version=5 +indicator("Complex Bracket Index") + +int strideInput = 1 +float rsiLong = ta.rsi(close[strideInput * 2], 3) + +plot(rsiLong, "rsi") +`; + const { plots } = await pineTS.run(code); + + const rsiData = plots['rsi'].data; + // RSI should produce valid values (not NaN/crash) after warmup period + let hasValidValue = false; + for (let i = 5; i < rsiData.length; i++) { + if (rsiData[i].value !== null && isFinite(rsiData[i].value)) { + hasValidValue = true; + break; + } + } + expect(hasValidValue).toBe(true); + }); + + it('complex index expression in user function (BinaryExpression)', async () => { + // Tests that src[stride * 3] inside a user function correctly transforms + // the function parameter `stride` inside the BinaryExpression index + const code = ` +//@version=5 +indicator("Complex Bracket in Function") + +myFunc(src, stride) => + ta.sma(src[stride * 3], 5) + +int strideInput = 1 +float test3 = myFunc(close, strideInput) + +plot(test3, "sma") +`; + const { plots } = await pineTS.run(code); + + const smaData = plots['sma'].data; + // SMA should produce valid values after warmup + let hasValidValue = false; + for (let i = 10; i < smaData.length; i++) { + if (smaData[i].value !== null && isFinite(smaData[i].value)) { + hasValidValue = true; + break; + } + } + expect(hasValidValue).toBe(true); + }); + + it('complex index in standalone bracket access (BinaryExpression via transformArrayIndex)', async () => { + // Tests that close[strideInput * 2] in a standalone assignment (not inside a namespace call) + // correctly transforms via transformArrayIndex — identifiers in the BinaryExpression index + // must be scoped ($.get($.let.glb1_strideInput, 0) * 2) + const code = ` +//@version=5 +indicator("Standalone Complex Bracket Index") + +int strideInput = 1 + +// Standalone bracket with BinaryExpression index (goes through transformArrayIndex, not transformFunctionArgument) +float test1 = close[strideInput * 2] +float test2 = close[strideInput + 1] + +plot(test1, "lookback_mul") +plot(test2, "lookback_add") +`; + const { plots } = await pineTS.run(code); + + const data1 = plots['lookback_mul'].data; + const data2 = plots['lookback_add'].data; + // Should produce valid values (not crash with "strideInput is not defined") + let hasValid1 = false; + let hasValid2 = false; + for (let i = 5; i < data1.length; i++) { + if (data1[i].value !== null && isFinite(data1[i].value)) hasValid1 = true; + if (data2[i].value !== null && isFinite(data2[i].value)) hasValid2 = true; + if (hasValid1 && hasValid2) break; + } + expect(hasValid1).toBe(true); + expect(hasValid2).toBe(true); + }); + it('loop variable UDT field access in call arguments', async () => { // Tests that element.field inside a for-in loop works correctly // inside function call arguments diff --git a/tests/transpiler/optional-chaining-get.test.ts b/tests/transpiler/optional-chaining-get.test.ts new file mode 100644 index 0000000..a1628a4 --- /dev/null +++ b/tests/transpiler/optional-chaining-get.test.ts @@ -0,0 +1,553 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2025 Alaa-eddine KADDOURI + +import { describe, it, expect } from 'vitest'; +import { PineTS, Provider } from 'index'; +import { transpile } from '../../src/transpiler/index'; + +/** + * Tests for optional chaining on method calls after $.get(). + * + * In Pine Script, calling methods on `na` is a silent no-op. In PineTS, `na` + * is NaN at runtime. Since NaN is not null/undefined, `NaN?.method` still + * accesses `.method` → `undefined`, and `undefined()` throws. Double optional + * chaining (`obj?.method?.()`) is needed so the call short-circuits safely. + * + * The transpiler adds this optional chaining to method calls on values + * retrieved via `$.get()`. Two patterns exist: + * + * Direct: $.get(X, N).method() → $.get(X, N)?.method?.() + * Chained: $.get(X, N).field.method() → $.get(X, N).field?.method?.() + * + * REGRESSION HISTORY: + * Commit 239c6fa (2026-03-10) broadened the condition to + * `hasGetCallInChain(node.callee)`, which matched intermediate `.get(0)` + * calls (e.g. in `arr.get(0).field.method()`) instead of the leaf method. + * Wrapping the intermediate call in ChainExpression blocked the leaf call + * from finding `$.get()` in its chain, so the leaf method lost its optional + * chaining entirely. Fixed by separating into isDirect and isChained cases + * and adding ChainExpression traversal to `hasGetCallInChain()`. + */ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makePineTS = () => + new PineTS(Provider.Mock, 'BTCUSDC', 'W', null, new Date('2019-01-01').getTime(), new Date('2019-03-01').getTime()); + +/** Transpile Pine Script source and return the generated code string */ +function transpileToString(pineCode: string): string { + const fn = transpile(pineCode); + return fn.toString(); +} + +// =========================================================================== +// A) Transpiler output tests — verify the generated code has correct patterns +// =========================================================================== + +describe('Optional chaining on $.get() — transpiler output', () => { + + // ----------------------------------------------------------------------- + // Case 1 — Direct: var drawing = na → $.get(X, N).delete() needs ?. + // ----------------------------------------------------------------------- + + it('direct pattern: var polyline = na → method gets optional chaining', () => { + const code = ` +//@version=6 +indicator("direct na var", overlay=true) +var polyline profilePoly = na +profilePoly.delete() +plot(close) +`; + const output = transpileToString(code); + // Should contain ?.delete?.() — double optional chaining + expect(output).toMatch(/\?\.\s*delete\s*\?\.\s*\(/); + }); + + it('direct pattern: var line = na → set_xy1 gets optional chaining', () => { + const code = ` +//@version=6 +indicator("direct na line", overlay=true) +var line myLine = na +myLine.set_xy1(bar_index, close) +plot(close) +`; + const output = transpileToString(code); + expect(output).toMatch(/\?\.\s*set_xy1\s*\?\.\s*\(/); + }); + + it('direct pattern: var label = na → get_text gets optional chaining', () => { + const code = ` +//@version=6 +indicator("direct na label", overlay=true) +var label myLabel = na +plot(myLabel.get_text() == "hi" ? 1 : 0) +`; + const output = transpileToString(code); + expect(output).toMatch(/\?\.\s*get_text\s*\?\.\s*\(/); + }); + + // ----------------------------------------------------------------------- + // Case 2 — Chained: UDT.field.method() where field could be na + // ----------------------------------------------------------------------- + + it('chained pattern: UDT.field.method() gets optional chaining on field', () => { + const code = ` +//@version=6 +indicator("chained UDT field", overlay=true) +type MyObj + box bx +var MyObj obj = MyObj.new() +obj.bx.delete() +plot(close) +`; + const output = transpileToString(code); + // The .delete() call on the field should have optional chaining + expect(output).toMatch(/\?\.\s*delete\s*\?\.\s*\(/); + }); + + it('chained pattern: UDT.lineField.get_x1() gets optional chaining', () => { + const code = ` +//@version=6 +indicator("chained get_x1", overlay=true) +type MyObj + line ln +var MyObj obj = MyObj.new() +plot(obj.ln.get_x1()) +`; + const output = transpileToString(code); + expect(output).toMatch(/\?\.\s*get_x1\s*\?\.\s*\(/); + }); + + // ----------------------------------------------------------------------- + // REGRESSION: array.get(N).field.method() — intermediate .get(N) + // must NOT steal optional chaining from the leaf method + // ----------------------------------------------------------------------- + + it('regression: arr.get(0).field.method() — leaf method gets optional chaining', () => { + const code = ` +//@version=6 +indicator("arr get field method", overlay=true) +type Ewave + box b5 +var Ewave[] aEW = array.new(0) +if barstate.isfirst + aEW.push(Ewave.new()) +aEW.get(0).b5.get_left() +plot(close) +`; + const output = transpileToString(code); + // The leaf .get_left() must have optional chaining + expect(output).toMatch(/\?\.\s*get_left\s*\?\.\s*\(/); + }); + + it('regression: arr.get(0).field.set_xy1() — leaf set method gets optional chaining', () => { + const code = ` +//@version=6 +indicator("arr get field set", overlay=true) +type MyWave + line l1 +var MyWave[] waves = array.new(0) +if barstate.isfirst + waves.push(MyWave.new()) +waves.get(0).l1.set_xy1(bar_index, close) +plot(close) +`; + const output = transpileToString(code); + // The leaf .set_xy1() must have optional chaining + expect(output).toMatch(/\?\.\s*set_xy1\s*\?\.\s*\(/); + }); + + it('regression: arr.get(0).field.delete() — leaf delete gets optional chaining', () => { + const code = ` +//@version=6 +indicator("arr get field delete", overlay=true) +type MyObj + label lb +var MyObj[] objs = array.new(0) +if barstate.isfirst + objs.push(MyObj.new()) +objs.get(0).lb.delete() +plot(close) +`; + const output = transpileToString(code); + expect(output).toMatch(/\?\.\s*delete\s*\?\.\s*\(/); + }); + + // ----------------------------------------------------------------------- + // Deep chain: arr.get(0).udtField.drawingField.method() + // ----------------------------------------------------------------------- + + it('deep chain: nested UDT field access still gets optional chaining on leaf', () => { + const code = ` +//@version=6 +indicator("deep chain", overlay=true) +type Inner + line ln +type Outer + Inner child +var Outer[] arr = array.new(0) +if barstate.isfirst + arr.push(Outer.new(Inner.new())) +arr.get(0).child.ln.get_x1() +plot(close) +`; + const output = transpileToString(code); + // The deep leaf .get_x1() must still have optional chaining + expect(output).toMatch(/\?\.\s*get_x1\s*\?\.\s*\(/); + }); + + // ----------------------------------------------------------------------- + // Negative cases — method calls that should NOT get optional chaining + // ----------------------------------------------------------------------- + + it('regular method calls (no $.get in chain) do NOT get optional chaining', () => { + const code = ` +//@version=6 +indicator("no optional", overlay=true) +sma = ta.sma(close, 20) +plot(sma) +`; + const output = transpileToString(code); + // ta.sma should NOT have optional chaining + expect(output).not.toMatch(/ta\s*\?\.\s*sma/); + }); + + it('math operations do NOT get optional chaining', () => { + const code = ` +//@version=6 +indicator("math no optional") +x = math.abs(-5) +plot(x) +`; + const output = transpileToString(code); + expect(output).not.toMatch(/math\s*\?\.\s*abs/); + }); +}); + + +// =========================================================================== +// B) Runtime tests — verify no crashes when UDT drawing fields are na +// =========================================================================== + +describe('Optional chaining on $.get() — runtime behavior', () => { + + // ----------------------------------------------------------------------- + // Direct pattern: var drawing = na → method calls silently return na + // ----------------------------------------------------------------------- + + it('var line = na → set_xy1 is a no-op (no crash)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("direct na line rt", overlay=true) +var line myLine = na +myLine.set_xy1(bar_index, close) +myLine.set_xy2(bar_index, low) +plot(close) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + }); + + it('var label = na → set_text is a no-op (no crash)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("direct na label rt", overlay=true) +var label myLabel = na +myLabel.set_text("test") +myLabel.set_xy(bar_index, close) +plot(close) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + }); + + it('var box = na → delete is a no-op (no crash)', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("direct na box rt", overlay=true) +var box myBox = na +myBox.delete() +plot(close) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + }); + + // ----------------------------------------------------------------------- + // Chained pattern: UDT.uninitializedField.method() → silent no-op + // ----------------------------------------------------------------------- + + it('UDT with uninitialized line field — set methods are no-ops', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("chained na line rt", overlay=true) +type MyObj + line ln +var MyObj obj = MyObj.new() +obj.ln.set_xy1(bar_index, close) +obj.ln.set_xy2(bar_index, low) +plot(close) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + }); + + it('UDT with uninitialized box field — get methods return na', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("chained na box get rt", overlay=true) +type MyObj + box bx +var MyObj obj = MyObj.new() +val = obj.bx.get_top() +plot(na(val) ? 0 : val, "result") +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + // val should be na → plot shows 0 + const lastVal = plots['result'].data[plots['result'].data.length - 1].value; + expect(lastVal).toBe(0); + }); + + // ----------------------------------------------------------------------- + // REGRESSION: array.get(N).field.method() with uninitialized field + // This is the exact pattern that broke Elliott Wave (commit 239c6fa) + // ----------------------------------------------------------------------- + + it('regression: arr.get(0).uninitField.get_x() does not crash', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("arr get uninit field rt", overlay=true) +type Ewave + box b5 + line l1 +var Ewave[] waves = array.new(0) +if barstate.isfirst + waves.push(Ewave.new()) +x = waves.get(0).b5.get_left() +y = waves.get(0).l1.get_x1() +plot(na(x) ? 0 : x) +plot(na(y) ? 0 : y) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + }); + + it('regression: arr.get(0).uninitField.set_xy1() does not crash', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("arr get uninit set rt", overlay=true) +type MyWave + line l1 + label lb +var MyWave[] waves = array.new(0) +if barstate.isfirst + waves.push(MyWave.new()) +waves.get(0).l1.set_xy1(bar_index, close) +waves.get(0).lb.set_text("test") +plot(close) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + }); + + it('regression: arr.get(0).uninitField.delete() does not crash', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("arr get uninit delete rt", overlay=true) +type MyObj + box bx + line ln + label lb +var MyObj[] objs = array.new(0) +if barstate.isfirst + objs.push(MyObj.new()) +objs.get(0).bx.delete() +objs.get(0).ln.delete() +objs.get(0).lb.delete() +plot(close) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + }); + + // ----------------------------------------------------------------------- + // Full Elliott-Wave-like pattern: UDT with multiple drawing fields, + // array access, and method calls on uninitialized fields + // ----------------------------------------------------------------------- + + it('Elliott Wave pattern: UDT array with mixed init/uninit drawing fields', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("EW pattern", overlay=true) +type Ewave + box b5 + line l1 + label lb + float price = na + +var Ewave[] waves = array.new(0) +if barstate.isfirst + waves.push(Ewave.new( + b5 = na, + l1 = line.new(na, na, na, na, color=#ff0000), + lb = na, + price = close + )) + +ew = waves.get(0) + +// Mix of initialized (l1) and uninitialized (b5, lb) fields +ew.l1.set_xy1(bar_index - 5, close) +ew.l1.set_xy2(bar_index, close) +ew.b5.get_left() +ew.lb.delete() +ew.price := close + +plot(ew.price) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + // The initialized line should exist + expect(plots['__lines__']).toBeDefined(); + }); + + // ----------------------------------------------------------------------- + // UDT method (Pine `method`) calling drawing methods on fields + // ----------------------------------------------------------------------- + + it('UDT method calling drawing methods on uninitialized field — no crash', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("UDT method uninit", overlay=true) +type MyFib + line ln1 + line ln2 + +method setLines(MyFib f, int x1, int x2, float y) => + f.ln1.set_xy1(x1, y) + f.ln1.set_xy2(x2, y) + f.ln2.set_xy1(x1, y + 10) + f.ln2.set_xy2(x2, y + 10) + +var MyFib fib = MyFib.new() +fib.setLines(bar_index - 5, bar_index, close) +plot(close) +`; + const { plots } = await pineTS.run(code); + // Should not crash even though ln1 and ln2 are both na + expect(plots).toBeDefined(); + }); + + // ----------------------------------------------------------------------- + // Conditional initialization: field is na on some bars, valid on others + // ----------------------------------------------------------------------- + + it('field conditionally initialized — methods work on both na and valid', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("conditional init", overlay=true) +type MyObj + line ln + +var MyObj obj = MyObj.new() +if barstate.isfirst + obj.ln := line.new(na, na, na, na, color=#ff0000) + +// On bar 0, ln is valid; before initialization it was na +obj.ln.set_xy1(bar_index, close) +obj.ln.set_xy2(bar_index, low) +plot(close) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + expect(plots['__lines__']).toBeDefined(); + const lines = plots['__lines__'].data[0].value.filter((l: any) => !l._deleted); + expect(lines.length).toBe(1); + }); + + // ----------------------------------------------------------------------- + // Initialized drawing fields should still work correctly + // (optional chaining should be transparent for non-na values) + // ----------------------------------------------------------------------- + + it('initialized UDT drawing fields work correctly with optional chaining', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("init fields work", overlay=true) +type MyObj + line ln + label lb + +var MyObj obj = MyObj.new( + ln = line.new(0, 100, 10, 200, color=#ff0000), + lb = label.new(0, 100, "test", color=#0000ff) +) +obj.ln.set_xy1(bar_index, high) +obj.ln.set_xy2(bar_index, low) +obj.lb.set_xy(bar_index, high) +obj.lb.set_text("Bar " + str.tostring(bar_index)) + +x1 = obj.ln.get_x1() +plot(x1, "x1_val") +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + + // Line should exist and have valid coordinates + const lines = plots['__lines__'].data[0].value.filter((l: any) => !l._deleted); + expect(lines.length).toBe(1); + expect(lines[0].x1).not.toBeNaN(); + expect(lines[0].color).toBe('#ff0000'); + + // Label should exist with updated text + const labels = plots['__labels__'].data[0].value.filter((l: any) => !l._deleted); + expect(labels.length).toBe(1); + expect(labels[0].text).toContain('Bar'); + + // get_x1() should return a valid number via plot + const plotData = plots['x1_val'].data; + const lastVal = plotData[plotData.length - 1].value; + expect(typeof lastVal).toBe('number'); + expect(lastVal).not.toBeNaN(); + }); + + it('arr.get(N) with initialized drawing field — methods produce correct results', async () => { + const pineTS = makePineTS(); + const code = ` +//@version=6 +indicator("arr init field", overlay=true) +type MyWave + line l1 + +var MyWave[] waves = array.new(0) +if barstate.isfirst + waves.push(MyWave.new(l1 = line.new(0, 100, 10, 200, color=#00ff00))) + +waves.get(0).l1.set_xy1(bar_index - 3, close) +waves.get(0).l1.set_xy2(bar_index, close) +plot(close) +`; + const { plots } = await pineTS.run(code); + expect(plots).toBeDefined(); + expect(plots['__lines__']).toBeDefined(); + const lines = plots['__lines__'].data[0].value.filter((l: any) => !l._deleted); + expect(lines.length).toBe(1); + expect(lines[0].color).toBe('#00ff00'); + expect(lines[0].x1).not.toBeNaN(); + }); +}); diff --git a/tests/transpiler/parser-fixes.test.ts b/tests/transpiler/parser-fixes.test.ts index 7722bfa..0aa77fe 100644 --- a/tests/transpiler/parser-fixes.test.ts +++ b/tests/transpiler/parser-fixes.test.ts @@ -1373,3 +1373,334 @@ line.new(bar_index[1], close[1], bar_index, close, expect(jsCode).toContain('line.style_dashed'); }); }); + +// --------------------------------------------------------------------------- +// 15. Keywords as Property Names (Member Access) +// --------------------------------------------------------------------------- +describe('Parser Fix: Keywords as Property Names', () => { + it('should parse syminfo.type (keyword "type" as property)', () => { + const code = ` +//@version=6 +indicator("Type Property Test") +isCrypto = syminfo.type == "crypto" +plot(isCrypto ? 1 : 0) +`; + const pine2js = pineToJS(code); + expect(pine2js.success).toBe(true); + expect(pine2js.code).toContain('syminfo.type'); + }); + + it('should parse syminfo.type in ternary expression', () => { + const code = ` +//@version=6 +indicator("Type Ternary") +val = syminfo.type == "crypto" ? 1 : 0 +plot(val) +`; + const pine2js = pineToJS(code); + expect(pine2js.success).toBe(true); + expect(pine2js.code).toContain("syminfo.type == 'crypto'"); + }); + + it('should parse multiple keyword properties in one script', () => { + const code = ` +//@version=6 +indicator("Multi Keyword Props") +t = syminfo.type +plot(close) +`; + const pine2js = pineToJS(code); + expect(pine2js.success).toBe(true); + expect(pine2js.code).toContain('syminfo.type'); + }); + + it('should transpile syminfo.type through full pipeline', () => { + const code = ` +//@version=6 +indicator("Type Full Pipeline") +isCrypto = syminfo.type == "crypto" +plot(isCrypto ? 1 : 0) +`; + const result = transpile(code); + const jsCode = result.toString(); + expect(jsCode).toBeDefined(); + expect(jsCode).toContain('syminfo.type'); + }); + + it('should run syminfo.type comparison at runtime', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', '60', null, new Date('2024-01-01').getTime(), new Date('2024-01-10').getTime()); + const code = ` +//@version=6 +indicator("Type Runtime") +_isCrypto = syminfo.type == "crypto" +plot(_isCrypto ? 1 : 0, "IsCrypto") +`; + const { plots } = await pineTS.run(code); + expect(plots['IsCrypto']).toBeDefined(); + // Mock provider uses crypto data, so syminfo.type should be "crypto" + const lastValue = plots['IsCrypto'].data[plots['IsCrypto'].data.length - 1].value; + expect(lastValue).toBe(1); + }); +}); + +// ─── 16. Tuple Destructuring After Switch Expression ──────────────── +describe('Parser Fix: Tuple destructuring after switch expression', () => { + it('should parse [a,b,c] = switch x without treating [ as postfix index', () => { + const code = ` +//@version=6 +indicator("Tuple Switch") +x = "opt1" +[a, b, c] = switch x + "opt1" => [1, 2, 3] + "opt2" => [4, 5, 6] + => [7, 8, 9] +plot(a) +`; + const result = transpile(code); + const jsCode = result.toString(); + expect(jsCode).toBeDefined(); + // The tuple destructuring should produce a let [a, b, c] = pattern + expect(jsCode).toContain('glb1_a'); + expect(jsCode).toContain('glb1_b'); + expect(jsCode).toContain('glb1_c'); + }); + + it('should parse tuple destructuring after switch with multiple cases', () => { + const code = ` +//@version=6 +indicator("Colormap Switch") +VIRIDIS = "Viridis" +PLASMA = "Plasma" +colormapInput = "Viridis" +[cold, lukewarm, hot] = switch colormapInput + VIRIDIS => ["#400A53", "#408E8B", "#F8E650"] + PLASMA => ["#110A81", "#B8487D", "#F1F455"] + => ["#000", "#888", "#FFF"] +plot(0) +`; + const result = transpile(code); + const jsCode = result.toString(); + expect(jsCode).toBeDefined(); + expect(jsCode).toContain('glb1_cold'); + expect(jsCode).toContain('glb1_lukewarm'); + expect(jsCode).toContain('glb1_hot'); + }); + + it('should still parse normal index access after expression', () => { + // Ensure we did not break regular index access like arr[0] + const code = ` +//@version=6 +indicator("Index Access") +a = array.new_float(3, 0.0) +b = array.get(a, 0) +plot(b) +`; + const result = transpile(code); + const jsCode = result.toString(); + expect(jsCode).toBeDefined(); + }); + + it('should run tuple destructuring after switch at runtime', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', '60', null, new Date('2024-01-01').getTime(), new Date('2024-01-10').getTime()); + const code = ` +//@version=6 +indicator("Tuple Switch Runtime") +x = "opt1" +[a, b, c] = switch x + "opt1" => [10, 20, 30] + "opt2" => [40, 50, 60] + => [70, 80, 90] +plot(a, "PlotA") +plot(b, "PlotB") +plot(c, "PlotC") +`; + // This test verifies parsing + transpilation + execution don't crash. + // The switch expression returning a tuple is correctly parsed now. + const result = await pineTS.run(code); + expect(result).toBeDefined(); + expect(result.plots).toBeDefined(); + }); +}); + +// ─── 17. Async Propagation for request.security in User Functions ─── +describe('Transpiler Fix: Async propagation for request.security in user-defined functions', () => { + it('should mark functions containing request.security as async', () => { + const code = ` +//@version=6 +indicator("Async Func", overlay=true) +getData() => + [d, m] = request.security(syminfo.tickerid, '1D', [close, volume]) + d + m +val = getData() +plot(val) +`; + const result = transpile(code); + const jsCode = result.toString(); + expect(jsCode).toBeDefined(); + // Function should be async + expect(jsCode).toContain('async function getData'); + // The call should be awaited + expect(jsCode).toMatch(/await \$\.call\(getData/); + }); + + it('should propagate async transitively through call chain', () => { + const code = ` +//@version=6 +indicator("Transitive Async", overlay=true) +inner() => + [d, m] = request.security(syminfo.tickerid, '1D', [close, volume]) + d + m +outer() => + inner() +val = outer() +plot(val) +`; + const result = transpile(code); + const jsCode = result.toString(); + expect(jsCode).toBeDefined(); + // Both functions should be async + expect(jsCode).toContain('async function inner'); + expect(jsCode).toContain('async function outer'); + // The outer call should be awaited + expect(jsCode).toMatch(/await \$\.call\(outer/); + }); + + it('should handle request.security inside switch in user function', () => { + // This tests the IIFE pattern: switch generates (() => { ... })() + // which also needs async propagation + const code = ` +//@version=6 +indicator("Switch Async", overlay=true) +gatherDays(float output) => + [dailyData, currentDay] = request.security(syminfo.tickerid, '1D', [output, dayofweek]) + dailyData +gatherData() => + float output = volume + switch "days" + "days" => gatherDays(output) +plot(0) +`; + const result = transpile(code); + const jsCode = result.toString(); + expect(jsCode).toBeDefined(); + // gatherDays should be async (contains await request.security) + expect(jsCode).toContain('async function gatherDays'); + // gatherData should also be async (calls async gatherDays through switch IIFE) + expect(jsCode).toContain('async function gatherData'); + }); + + it('should run request.security in user function with Binance provider', async () => { + const pineTS = new PineTS(Provider.Binance, 'BTCUSDC', '1W', 50, new Date('2024-01-01').getTime()); + const code = ` +//@version=6 +indicator("Async Runtime", overlay=true) +getData() => + request.security(syminfo.tickerid, '1D', close) +val = getData() +plot(val, "Val") +`; + // This should NOT throw "await is only valid in async functions" + const result = await pineTS.run(code); + expect(result).toBeDefined(); + expect(result.plots).toBeDefined(); + }, 30000); +}); + +// ─── 18. Function Parameter Renaming for Namespace Collisions ─────── +describe('Codegen Fix: Function parameter renaming for namespace collisions', () => { + it('should rename param "color" to avoid collision with color namespace', () => { + const code = ` +//@version=6 +indicator("Param Rename Color") +myFunc(color = "#FFF") => + color +plot(0) +`; + const pine2js = pineToJS(code); + expect(pine2js.success).toBe(true); + // The param should be renamed to color_$ + expect(pine2js.code).toMatch(/color_\$\d+/); + // Original bare 'color' should NOT appear as a parameter name + expect(pine2js.code).not.toMatch(/function myFunc\([^)]*\bcolor\b[^_]/); + }); + + it('should rename param "line" to avoid collision with line namespace', () => { + const code = ` +//@version=6 +indicator("Param Rename Line") +draw(line, int x) => + x + 1 +plot(draw(1, 2)) +`; + const pine2js = pineToJS(code); + expect(pine2js.success).toBe(true); + // The param 'line' should be renamed + expect(pine2js.code).toMatch(/line_\$\d+/); + }); + + it('should rename references in function body to match renamed param', () => { + const code = ` +//@version=6 +indicator("Param Body Rename") +cell(string data, color = "#FFF") => + color +plot(0) +`; + const pine2js = pineToJS(code); + expect(pine2js.success).toBe(true); + // The return expression should use the renamed parameter + const match = pine2js.code.match(/color_\$(\d+)/g); + // Should appear at least twice: once in param, once in body reference + expect(match).not.toBeNull(); + expect(match.length).toBeGreaterThanOrEqual(2); + }); + + it('should NOT rename params that do not collide with known names', () => { + const code = ` +//@version=6 +indicator("No Rename") +myFunc(x, y, z) => + x + y + z +plot(myFunc(1, 2, 3)) +`; + const pine2js = pineToJS(code); + expect(pine2js.success).toBe(true); + // No _$ param renames should exist + expect(pine2js.code).not.toMatch(/_\$\d+/); + // Function keeps its original name, params (x, y, z) stay unchanged + expect(pine2js.code).toContain('function myFunc(x, y, z)'); + }); + + it('should transpile renamed param through full pipeline without __value error', () => { + const code = ` +//@version=6 +indicator("Full Pipeline Param Rename") +cell(string data, color = "#FFFFFF") => + color +val = cell("test", "#00FF00") +plot(0) +`; + const result = transpile(code); + const jsCode = result.toString(); + expect(jsCode).toBeDefined(); + // Should NOT have color.__value (the bug this fix addresses) + expect(jsCode).not.toContain('color.__value'); + // The renamed param should flow through + expect(jsCode).toMatch(/color_\$\d+/); + }); + + it('should run function with renamed color param at runtime', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', '60', null, new Date('2024-01-01').getTime(), new Date('2024-01-10').getTime()); + const code = ` +//@version=6 +indicator("Runtime Param Rename") +myCell(string data, color = "#FFFFFF") => + color +val = myCell("test", "#00FF00") +plot(val == "#00FF00" ? 1 : 0, "Check") +`; + const result = await pineTS.run(code); + expect(result).toBeDefined(); + expect(result.plots).toBeDefined(); + }); +}); diff --git a/tests/transpiler/pinescript-to-js.test.ts b/tests/transpiler/pinescript-to-js.test.ts index 84c5b61..cc7285b 100644 --- a/tests/transpiler/pinescript-to-js.test.ts +++ b/tests/transpiler/pinescript-to-js.test.ts @@ -489,8 +489,8 @@ plot(size) const result = transpile(code); const jsCode = result.toString(); - expect(jsCode).toContain('.push('); - expect(jsCode).toContain('.size()'); + expect(jsCode).toContain('?.push?.('); + expect(jsCode).toContain('?.size?.()'); }); }); diff --git a/tests/transpiler/pinets-source-to-js.test.ts b/tests/transpiler/pinets-source-to-js.test.ts index 01a1ba3..7403a5d 100644 --- a/tests/transpiler/pinets-source-to-js.test.ts +++ b/tests/transpiler/pinets-source-to-js.test.ts @@ -1045,9 +1045,9 @@ let src_open = input.any({ title: 'Open Source', defval: open }); const p0 = array.param(5, undefined, 'p0'); const temp_1 = array.new_float(p0); $.let.glb1_a = $.init($.let.glb1_a, temp_1); - $.get($.let.glb1_a, 0).fill($.get(close, 1) - $.get(open, 0)); + $.get($.let.glb1_a, 0)?.fill?.($.get(close, 1) - $.get(open, 0)); $.let.glb1_res = $.init($.let.glb1_res, $.get($.let.glb1_a, 0)); - $.let.glb1_i = $.init($.let.glb1_i, $.get($.let.glb1_a, 0).indexof($.get(high, 0))); + $.let.glb1_i = $.init($.let.glb1_i, $.get($.let.glb1_a, 0)?.indexof?.($.get(high, 0))); }`; expect(result).toBe(expected_code); @@ -1246,8 +1246,8 @@ let src_open = input.any({ title: 'Open Source', defval: open }); active: "bool" }, undefined, 'p0'); $.const.glb1_Trade = $.init($.const.glb1_Trade, Type(p0)); - $.let.glb1_trade = $.init($.let.glb1_trade, $.get($.const.glb1_Trade, 0).new($.get(close, 0), $.get(open, 0), $.get(high, 0), $.get(close, 0) > $.get(open, 0))); - $.let.glb1_trade2 = $.init($.let.glb1_trade2, $.get($.let.glb1_trade, 0).copy()); + $.let.glb1_trade = $.init($.let.glb1_trade, $.get($.const.glb1_Trade, 0)?.new?.($.get(close, 0), $.get(open, 0), $.get(high, 0), $.get(close, 0) > $.get(open, 0))); + $.let.glb1_trade2 = $.init($.let.glb1_trade2, $.get($.let.glb1_trade, 0)?.copy?.()); $.get($.let.glb1_trade2, 0).active = false; const p1 = ta.param($.get($.let.glb1_trade, 0).entry, undefined, 'p1'); const p2 = ta.param(14, undefined, 'p2'); @@ -1338,8 +1338,9 @@ let src_open = input.any({ title: 'Open Source', defval: open }); const result = transpiled.toString().trim(); // All three usages should transform variables consistently + // != is now transpiled to $.pine.math.__neq() for na-aware inequality const expectedPattern = - /\$\.get\(\$\.let\.glb1_buy, 0\) && \$\.get\(\$\.let\.glb1_xs, 0\) != \$\.get\(\$\.let\.glb1_xs, 1\) && \$\.get\(\$\.let\.glb1_direction, 0\) < 0/g; + /\$\.get\(\$\.let\.glb1_buy, 0\) && \$\.pine\.math\.__neq\(\$\.get\(\$\.let\.glb1_xs, 0\), \$\.get\(\$\.let\.glb1_xs, 1\)\) && \$\.get\(\$\.let\.glb1_direction, 0\) < 0/g; const matches = result.match(expectedPattern); // Should appear 3 times: in plotshape arg, foo arg, and buyCond assignment diff --git a/tests/transpiler/scope-edge-cases.test.ts b/tests/transpiler/scope-edge-cases.test.ts index 32e521f..894d96c 100644 --- a/tests/transpiler/scope-edge-cases.test.ts +++ b/tests/transpiler/scope-edge-cases.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest'; import { PineTS } from '../../src/PineTS.class'; import { Provider } from '@pinets/marketData/Provider.class'; +import { transpile } from '../../src/transpiler'; describe('Transpiler Scope Edge Cases', () => { it('should handle deeply nested scopes with name collisions', async () => { @@ -304,5 +305,30 @@ plot(val2, "V2") expect(plots['result']).toBeDefined(); expect(plots['result'].data[0].value).toBe(200); }); + + it('should scope function parameters correctly inside array expressions', async () => { + // Regression test: function parameters inside ArrayExpression args to namespace + // methods were scoped to $.get($.let.output, 0) (global, doesn't exist) instead of + // $.get(output, 0) (raw JS parameter). Verify via transpiler output. + const indicatorCode = ` +//@version=5 +indicator("Func Param in Array Expr") + +gather(float output) => + [secData, secClose] = request.security(syminfo.tickerid, "D", [output, close]) + secData + +result = gather(volume) +plot(result, "Result") +`; + + const transpiledFn = transpile(indicatorCode, { debug: false }); + const code = transpiledFn.toString(); + + // Function parameter 'output' inside array arg [output, close] must use + // the raw identifier, NOT $.let.output (which would reference a non-existent global). + expect(code).toContain('$.get(output, 0)'); + expect(code).not.toMatch(/\$\.get\(\$\$?\.let\.\w*output/); + }); }); diff --git a/tests/transpiler/switch.test.ts b/tests/transpiler/switch.test.ts index 13840a9..3349201 100644 --- a/tests/transpiler/switch.test.ts +++ b/tests/transpiler/switch.test.ts @@ -168,6 +168,7 @@ plot(result) expect(code).toContain('$.let.glb1_length = $.init($.let.glb1_length, 10)'); // Function should use its parameters directly (not transformed) + // Function should use its original name expect(code).toMatch(/function ma\(source, length, maType\)/); // Inside function, parameters should be used directly @@ -804,9 +805,91 @@ plot(result, "Result") const { plots } = await pineTS.run(indicatorCode); expect(plots['Result']).toBeDefined(); - + const lastValue = plots['Result'].data[plots['Result'].data.length - 1].value; expect(lastValue).toBe(100); }); + + it('should preserve tuple values from switch expression destructuring', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', '60', null, new Date('2024-01-01').getTime(), new Date('2024-01-10').getTime()); + + // This tests the bug where $.init() treated the flat array returned by + // a switch IIFE as time-series data, keeping only the last element. + // [a, b, c] = switch x { case: [10, 20, 30] } should destructure correctly. + const indicatorCode = ` +//@version=6 +indicator("Switch Tuple Destructuring") + +selector = "first" +[a, b, c] = switch selector + "first" => [10.0, 20.0, 30.0] + "second" => [40.0, 50.0, 60.0] + => [0.0, 0.0, 0.0] + +plot(a, "A") +plot(b, "B") +plot(c, "C") +`; + + const { plots } = await pineTS.run(indicatorCode); + + expect(plots['A']).toBeDefined(); + expect(plots['B']).toBeDefined(); + expect(plots['C']).toBeDefined(); + + const lastA = plots['A'].data[plots['A'].data.length - 1].value; + const lastB = plots['B'].data[plots['B'].data.length - 1].value; + const lastC = plots['C'].data[plots['C'].data.length - 1].value; + + // Without the fix, $.init() takes only the last element (30.0), + // then string-indexes it, producing undefined for all three. + expect(lastA).toBe(10); + expect(lastB).toBe(20); + expect(lastC).toBe(30); + }); + + it('should preserve string tuple values from switch (color constants)', async () => { + const pineTS = new PineTS(Provider.Mock, 'BTCUSDC', '60', null, new Date('2024-01-01').getTime(), new Date('2024-01-10').getTime()); + + // Regression test for PTAG grayscale bug: color hex strings from switch + // destructuring were reduced to single characters ('#', 'F', '8'). + const indicatorCode = ` +//@version=6 +indicator("Switch Color Tuple") + +COLD = '#400A53' +MEDIUM = '#408E8B' +HOT = '#F8E650' + +mode = "viridis" +[cold, medium, hot] = switch mode + "viridis" => [COLD, MEDIUM, HOT] + => ['#000000', '#888888', '#FFFFFF'] + +// Use color.r() to extract the red channel — proves the color is a valid hex string +r_cold = color.r(cold) +r_medium = color.r(medium) +r_hot = color.r(hot) + +plot(r_cold, "R_Cold") +plot(r_medium, "R_Medium") +plot(r_hot, "R_Hot") +`; + + const { plots } = await pineTS.run(indicatorCode); + + expect(plots['R_Cold']).toBeDefined(); + expect(plots['R_Medium']).toBeDefined(); + expect(plots['R_Hot']).toBeDefined(); + + const rCold = plots['R_Cold'].data[plots['R_Cold'].data.length - 1].value; + const rMedium = plots['R_Medium'].data[plots['R_Medium'].data.length - 1].value; + const rHot = plots['R_Hot'].data[plots['R_Hot'].data.length - 1].value; + + // #400A53 → R=64, #408E8B → R=64, #F8E650 → R=248 + expect(rCold).toBe(64); + expect(rMedium).toBe(64); + expect(rHot).toBe(248); + }); }); });