diff --git a/CHANGELOG.md b/CHANGELOG.md index 3301c91..148dd36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ## [Unreleased] +## [3.3.0] - 2026-09-08 + +### Added + +- Go to Definition, Find All References, Document Highlight and Rename Symbol for functions, methods, types, enums, enum members, variables, parameters and import aliases. Renaming understands scope and leaves named arguments, import paths and type fields alone; renaming a built-in is refused. +- Offline checks that run as you type, with no network: `missing-version`, `old-version`, `legacy-name`, `local-scope-call`, `unknown-argument`, `duplicate-argument`, `unused-variable`, `unused-parameter` and `unused-import`. Most carry a quick fix. `pinescript.lint.enabled` and `pinescript.lint.disabledRules` control them. +- **Pine Script: Convert to v6**, which applies the version, legacy-name and argument fixes across a whole file and reports exactly what it changed. +- Colour swatches with a picker for hex literals, the built-in colour constants, `color.new()` and `color.rgb()`, writing the result back as a hex literal or an rgb call. +- Semantic highlighting for the identifiers a document declares, so parameters, enum members, type fields and import aliases are coloured for what they are. Both bundled themes gained matching `semanticTokenColors`. + +### Changed + +- The bundled themes enable semantic highlighting, which they previously switched off. + ## [3.2.0] - 2026-09-08 ### Added diff --git a/README.md b/README.md index 2dfc119..0759132 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@
-

Completion, hover documentation, signature help, formatting, quick fixes, 97 snippets and themes for TradingView Pine Script® v6 in Visual Studio Code.

+

Completion, navigation, hover documentation, signature help, formatting, offline checks, colour swatches, 97 snippets and themes for TradingView Pine Script® v6 in Visual Studio Code.

Marketplace version @@ -21,7 +21,7 @@ ## Why you will like it -Open a `.pine` file and the editor already knows the language: every v6 built-in completes with its signature, hovering anything shows the reference entry, parameter hints follow you through a call, **Format Document** lays the script out the way Pine wants it, and **97 snippets** turn a prefix and Tab into a full indicator, a strategy exit block or a Bollinger Bands section. Nothing leaves your machine unless you opt in, and the whole thing weighs less than a megabyte. +Open a `.pine` file and the editor already knows the language: every v6 built-in completes with its signature, F12 jumps to your own declarations, F2 renames a symbol everywhere, hovering anything shows the reference entry, **Format Document** lays the script out the way Pine wants it, problems are flagged as you type without your code leaving the machine, colours show a swatch you can click, and **97 snippets** turn a prefix and Tab into a full indicator, a strategy exit block or a Bollinger Bands section. ## Quick start @@ -31,7 +31,8 @@ Open a `.pine` file and the editor already knows the language: every v6 built-in 4. Hover over `input.int`, `ta.crossover` or your own function to read what it does. 5. Try `bb`, `sltp`, `table` or `request.security.tuple` with Tab to drop in a working building block. 6. Press Shift+Alt+F (Shift+Option+F on macOS) to format the file: blocks get their four spaces, operators and arguments get their spacing, and nothing else moves. -7. Press F1 and type `Pine Script:` to see the commands: new files from templates, docstrings, type annotations and the reference. +7. Put the cursor on one of your own functions and press F12 to jump to it, Shift+F12 to list every call, or F2 to rename it everywhere at once. +8. Press F1 and type `Pine Script:` to see the commands: new files from templates, docstrings, type annotations, converting an old script to v6, and the reference. ## Features @@ -42,6 +43,12 @@ Open a `.pine` file and the editor already knows the language: every v6 built-in - Named arguments inside a call, `//@` annotations at the start of a comment, and `import` paths that complete user, library and version. - Function items insert a call and open parameter hints, so a long `strategy.exit()` is a matter of tabbing through its parameters. +### Navigation through your own code + +- **Go to Definition** (F12) on a function, method, type, enum, enum member, variable, parameter or import alias jumps to where you declared it. +- **Find All References** (Shift+F12) lists every use, and the occurrences under the cursor are highlighted as you move around. +- **Rename Symbol** (F2) renames a declaration and every use of it in one edit. It understands scope, so a global `length` is not touched when a function takes its own `length` parameter, and it leaves named arguments, import paths and type fields alone. Renaming a built-in is refused rather than half done. + ### Documentation where the cursor is - **Hover** on a built-in shows the signature, description, parameters, return value and a link to the reference entry. Hover on your own symbol shows its declaration and its `//@` docs. Hover on an `import` line shows a card for the library. @@ -90,6 +97,31 @@ Padding used to align a column of assignments is collapsed to one space, which i Every snippet in this extension and every test fixture has been run through the formatter and back through the TradingView compiler: none of them changed meaning, and formatting twice gives the same file. +### Checks that never leave your machine + +Every Pine document is checked as you type, using the reference data that ships with the extension. This is on by default because nothing is sent anywhere; the TradingView compiler is still a separate, opt-in setting. + +| Rule | What it catches | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `missing-version` / `old-version` | No `//@version`, or one older than v6. Both offer a fix. | +| `legacy-name` | A bare v4 name that moved into a namespace: `sma`, `security`, `tostring`, `abs`. Offers the v6 name. | +| `local-scope-call` | `plot`, `hline`, `fill`, `bgcolor`, `alertcondition` and friends inside an `if`, `for` or function body, which the compiler rejects. | +| `unknown-argument` | A named argument the function does not take, including options dropped between versions such as `transp`. Suggests the near miss. | +| `duplicate-argument` | The same named argument passed twice. | +| `unused-variable` / `unused-parameter` / `unused-import` | Declarations nothing reads, shown faded rather than as warnings. | + +Turn the whole thing off with `pinescript.lint.enabled`, or silence single rules with `pinescript.lint.disabledRules`. + +**Pine Script: Convert to v6** applies the version, legacy-name and argument fixes across the whole file in one go, which is most of the work of bringing an old script forward. It says exactly what it changed, including any argument it had to remove, and it is a single undo. + +### Colours you can see and click + +Every colour a script names gets a swatch in the gutter of the line: hex literals such as `#FF9800`, the built-in constants, `color.new(color.blue, 25)` and `color.rgb(255, 152, 0, 40)`. Click one to open the colour picker; the value is written back as a hex literal or a `color.rgb()` call, transparency included. + +### Highlighting that knows your symbols + +On top of the grammar, the extension tells the editor which identifiers are yours. Parameters inside a function body, enum members, type fields and import aliases are coloured for what they are rather than guessed at from their spelling. Both bundled themes carry matching colours; other themes pick it up through the standard token types. + ### Structure, libraries and diagnostics - **Outline.** Functions, methods, types with fields, enums with members and top-level variables in the Outline view and breadcrumbs. @@ -133,6 +165,7 @@ The grammar and the documentation data are generated from the v6 reference, so v | Pine Script: Generate Docstring | Inserts or completes `//@function`, `//@param`, `//@returns`, `//@type`, `//@field`, `//@enum` for the declaration at the cursor | | Pine Script: Add Type Annotations | Prefixes untyped declarations with their inferred type in the selection or the whole file | | Pine Script: Open Reference | Opens the v6 reference at the built-in under the cursor | +| Pine Script: Convert to v6 | Adds or raises the version pragma and rewrites the v4 names that moved into namespaces | ## Settings @@ -145,6 +178,8 @@ The grammar and the documentation data are generated from the v6 reference, so v | `pinescript.libraries.remote` | `true` | Look up published libraries on TradingView for `import` completion and hover | | `pinescript.diagnostics.remote` | `false` | Send the document to the TradingView compiler for diagnostics | | `pinescript.format.enabled` | `true` | Format Document and Format Selection | +| `pinescript.lint.enabled` | `true` | Offline checks; nothing is sent anywhere | +| `pinescript.lint.disabledRules` | `[]` | Rule names the offline checker should skip | ## Privacy @@ -172,10 +207,12 @@ src/ reference.json full v6 documentation, generated by scripts/scrape-reference.mjs extension/ core/ pure TypeScript: tokenizer, document model, completion context, - type inference, docstrings, formatter, quick fixes, templates, + symbol resolution, type inference, docstrings, formatter, + offline rules, quick fixes, colours, semantic tokens, templates, TradingView client providers/ VS Code adapters: completion, hover, signature help, symbols, - formatting, code actions, diagnostics, library index + navigation, formatting, colours, semantic tokens, code actions, + diagnostics, offline checks, library index commands/ command implementations scripts/ build-grammar.mjs compiles src/ into syntaxes/pinescript.tmLanguage.json and diff --git a/package.json b/package.json index 92ebb32..8de19b2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "pine-script-syntax-highlighter", "displayName": "Pine Script Syntax Highlighter", "description": "Pine Script v6 for Visual Studio Code: highlighting, completion, hover documentation, signature help, snippets, themes and compiler diagnostics.", - "version": "3.2.0", + "version": "3.3.0", "publisher": "ex-codes", "author": { "name": "Yankı Küçük", @@ -115,6 +115,30 @@ "type": "boolean", "default": true, "description": "Format Pine documents on request, indenting blocks with four spaces and spacing operators and arguments." + }, + "pinescript.lint.enabled": { + "type": "boolean", + "default": true, + "description": "Check the open document for problems without sending it anywhere: names left over from older versions, calls that only work at the top level, unknown or repeated arguments, and declarations that are never read." + }, + "pinescript.lint.disabledRules": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "missing-version", + "old-version", + "legacy-name", + "local-scope-call", + "duplicate-argument", + "unknown-argument", + "unused-variable", + "unused-parameter", + "unused-import" + ] + }, + "default": [], + "description": "Rules the offline checker should not report." } } }, @@ -144,6 +168,11 @@ { "command": "pinescript.openReference", "title": "Pine Script: Open Reference" + }, + { + "command": "pinescript.convertToV6", + "title": "Pine Script: Convert to v6", + "enablement": "editorLangId == pinescript" } ], "menus": { @@ -162,6 +191,11 @@ "command": "pinescript.openReference", "when": "editorLangId == pinescript", "group": "1_pinescript@3" + }, + { + "command": "pinescript.convertToV6", + "when": "editorLangId == pinescript", + "group": "1_pinescript@4" } ] }, diff --git a/src/extension/commands/convert-to-v6.ts b/src/extension/commands/convert-to-v6.ts new file mode 100644 index 0000000..32a22d1 --- /dev/null +++ b/src/extension/commands/convert-to-v6.ts @@ -0,0 +1,57 @@ +import * as vscode from 'vscode'; + +import { lint, TARGET_VERSION, type LintIssue } from '../core/lint'; +import type { ReferenceIndex } from '../core/reference'; +import { analyze } from '../vscode/document-cache'; + +/** The rules whose fixes together move a script from an older Pine version to the current one. */ +const MIGRATION_RULES = new Set(['missing-version', 'old-version', 'legacy-name', 'unknown-argument']); + +export function registerConvertToV6(context: vscode.ExtensionContext, ref: ReferenceIndex): void { + context.subscriptions.push( + vscode.commands.registerCommand('pinescript.convertToV6', async () => { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.languageId !== 'pinescript') return; + const document = editor.document; + const issues = lint(analyze(document), ref).filter((i) => MIGRATION_RULES.has(i.rule) && i.fix); + if (!issues.length) { + void vscode.window.showInformationMessage(`This script already reads as Pine Script v${TARGET_VERSION}.`); + return; + } + const edit = new vscode.WorkspaceEdit(); + for (const issue of issues) { + for (const change of issue.fix!.edits) { + edit.replace( + document.uri, + new vscode.Range(change.startLine, change.startCol, change.endLine, change.endCol), + change.newText, + ); + } + } + const applied = await vscode.workspace.applyEdit(edit); + if (!applied) { + void vscode.window.showWarningMessage('The conversion could not be applied.'); + return; + } + void vscode.window.showInformationMessage(`Converted to v${TARGET_VERSION}: ${summarise(issues)}.`); + }), + ); +} + +/** Says plainly what the command changed, since one of its fixes deletes an argument. */ +function summarise(issues: LintIssue[]): string { + const count = (rule: string) => issues.filter((i) => i.rule === rule).length; + const removed = issues.filter((i) => i.rule === 'unknown-argument' && i.fix!.title.startsWith('Remove')).length; + const renamedArguments = count('unknown-argument') - removed; + const parts: string[] = []; + if (count('missing-version') || count('old-version')) parts.push('the version pragma'); + parts.push(...plural(count('legacy-name'), 'name')); + parts.push(...plural(renamedArguments, 'argument name')); + if (removed) parts.push(`${removed} ${removed === 1 ? 'argument' : 'arguments'} v6 does not accept, removed`); + return parts.join(', '); +} + +function plural(count: number, noun: string): string[] { + if (!count) return []; + return [`${count} ${noun}${count === 1 ? '' : 's'}`]; +} diff --git a/src/extension/core/colors.ts b/src/extension/core/colors.ts new file mode 100644 index 0000000..a97746b --- /dev/null +++ b/src/extension/core/colors.ts @@ -0,0 +1,187 @@ +import type { ReferenceIndex } from './reference'; +import type { Token, TokenizedLine } from './tokenizer'; + +/** A colour with each channel from 0 to 1, matching the editor's colour type. */ +export interface Rgba { + red: number; + green: number; + blue: number; + alpha: number; +} + +export interface ColorSpot { + line: number; + startCol: number; + endCol: number; + color: Rgba; +} + +const RE_HEX = /^#([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$/; +const RE_CONSTANT_VALUE = /#([0-9a-fA-F]{6})/; + +let namedColors: Map | undefined; + +/** The built-in colour constants, read from the reference so they stay in step with it. */ +function constants(ref: ReferenceIndex): Map { + if (namedColors) return namedColors; + namedColors = new Map(); + for (const entry of ref.members('color')) { + if (entry.kind !== 'constant') continue; + const match = RE_CONSTANT_VALUE.exec(entry.description); + if (match) namedColors.set(entry.name, fromHex(match[1]!, null)); + } + return namedColors; +} + +/** Finds every colour the editor can show a swatch for. */ +export function findColors(tokens: TokenizedLine[], ref: ReferenceIndex): ColorSpot[] { + const named = constants(ref); + const spots: ColorSpot[] = []; + for (let line = 0; line < tokens.length; line++) { + const significant = tokens[line]!.tokens.filter((t) => t.kind !== 'ws' && t.kind !== 'comment'); + for (let i = 0; i < significant.length; i++) { + const token = significant[i]!; + if (token.kind === 'number' && token.text.startsWith('#')) { + const color = parseHex(token.text); + if (color) spots.push({ line, startCol: token.start, endCol: token.end, color }); + continue; + } + if (token.kind !== 'ident') continue; + if (significant[i + 1]?.kind === 'open' && (token.text === 'color.new' || token.text === 'color.rgb')) { + const call = readCall(significant, i, named); + if (call) { + spots.push({ line, startCol: token.start, endCol: call.end, color: call.color }); + i = call.lastToken; + continue; + } + } + const constant = named.get(token.text); + if (constant) spots.push({ line, startCol: token.start, endCol: token.end, color: constant }); + } + } + return spots; +} + +/** The ways the picked colour can be written back, most idiomatic first. */ +export function colorPresentations(color: Rgba): string[] { + const transparency = Math.round((1 - color.alpha) * 100); + const channels = [color.red, color.green, color.blue].map(to255); + const rgb = transparency === 0 ? channels.join(', ') : `${channels.join(', ')}, ${transparency}`; + return [toHex(color), `color.rgb(${rgb})`]; +} + +interface ParsedCall { + color: Rgba; + end: number; + lastToken: number; +} + +/** Reads `color.new(base, transparency)` or `color.rgb(r, g, b, transparency)` on a single line. */ +function readCall(tokens: Token[], index: number, named: Map): ParsedCall | null { + const args = readArguments(tokens, index + 1); + if (!args) return null; + const name = tokens[index]!.text; + const color = name === 'color.new' ? fromNew(args.values, named) : fromRgb(args.values.map((v) => v && numberOf(v))); + if (!color) return null; + return { color, end: args.end, lastToken: args.lastToken }; +} + +function fromNew(values: (Token | null)[], named: Map): Rgba | null { + const base = values[0]; + if (!base) return null; + const color = base.kind === 'ident' ? (named.get(base.text) ?? null) : parseHex(base.text); + if (!color) return null; + const transparency = values.length > 1 ? numberOf(values[1] ?? null) : 0; + if (transparency === null) return null; + return { ...color, alpha: clamp01(1 - transparency / 100) }; +} + +function fromRgb(values: (number | null)[]): Rgba | null { + if (values.length < 3 || values.slice(0, 3).some((v) => v === null)) return null; + const transparency = values.length > 3 ? values[3] : 0; + if (transparency === null || transparency === undefined) return null; + return { + red: clamp01(values[0]! / 255), + green: clamp01(values[1]! / 255), + blue: clamp01(values[2]! / 255), + alpha: clamp01(1 - transparency / 100), + }; +} + +interface Arguments { + /** One token per argument, or null when the argument is not a plain literal. */ + values: (Token | null)[]; + end: number; + lastToken: number; +} + +/** + * Splits the arguments of a call whose `(` is at `open`. Named arguments are accepted, and any + * argument that is more than a single literal is reported as null so the caller can give up. + */ +function readArguments(tokens: Token[], open: number): Arguments | null { + const values: (Token | null)[] = []; + let current: Token[] = []; + let depth = 0; + for (let i = open; i < tokens.length; i++) { + const token = tokens[i]!; + if (token.kind === 'open') { + depth++; + if (depth === 1) continue; + } else if (token.kind === 'close') { + depth--; + if (depth === 0) { + values.push(single(current)); + return { values, end: token.end, lastToken: i }; + } + } else if (token.kind === 'comma' && depth === 1) { + values.push(single(current)); + current = []; + continue; + } + current.push(token); + } + return null; +} + +/** Reduces one argument to its literal, dropping a `name =` prefix and rejecting anything else. */ +function single(tokens: Token[]): Token | null { + const parts = tokens[1]?.text === '=' ? tokens.slice(2) : tokens; + if (parts.length !== 1) return null; + const token = parts[0]!; + return token.kind === 'number' || token.kind === 'ident' ? token : null; +} + +function numberOf(token: Token | null): number | null { + if (!token || token.kind !== 'number' || token.text.startsWith('#')) return null; + const value = Number(token.text); + return Number.isFinite(value) ? value : null; +} + +function parseHex(text: string): Rgba | null { + const match = RE_HEX.exec(text); + return match ? fromHex(match[1]!, match[2] ?? null) : null; +} + +function fromHex(rgb: string, alpha: string | null): Rgba { + return { + red: parseInt(rgb.slice(0, 2), 16) / 255, + green: parseInt(rgb.slice(2, 4), 16) / 255, + blue: parseInt(rgb.slice(4, 6), 16) / 255, + alpha: alpha === null ? 1 : parseInt(alpha, 16) / 255, + }; +} + +function toHex(color: Rgba): string { + const pair = (value: number) => to255(value).toString(16).padStart(2, '0').toUpperCase(); + const base = `#${pair(color.red)}${pair(color.green)}${pair(color.blue)}`; + return color.alpha >= 1 ? base : `${base}${pair(color.alpha)}`; +} + +function to255(value: number): number { + return Math.round(clamp01(value) * 255); +} + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, value)); +} diff --git a/src/extension/core/lint.ts b/src/extension/core/lint.ts new file mode 100644 index 0000000..11d546c --- /dev/null +++ b/src/extension/core/lint.ts @@ -0,0 +1,365 @@ +import { argumentSpan, renamedTo, suggestNames, type QuickFix } from './quick-fix'; +import type { ReferenceIndex } from './reference'; +import { + calleeOf, + flattenTokens, + isImportPath, + isMemberAccess, + isNamedArgument, + occurrencesOf, + type SymbolSource, +} from './symbols'; +import { nearest } from './text'; +import type { Token, TokenizedLine } from './tokenizer'; + +export type LintSeverity = 'error' | 'warning' | 'information' | 'hint'; + +export interface LintIssue { + rule: string; + line: number; + startCol: number; + endCol: number; + message: string; + severity: LintSeverity; + /** Renders the range faded, the way editors show dead code. */ + unnecessary: boolean; + fix: QuickFix | null; +} + +export interface LintSource extends SymbolSource { + lines: string[]; +} + +export const TARGET_VERSION = 6; + +/** Calls the compiler rejects anywhere but the top level. */ +const TOP_LEVEL_ONLY = new Set([ + 'plot', + 'plotshape', + 'plotchar', + 'plotarrow', + 'plotcandle', + 'plotbar', + 'hline', + 'fill', + 'bgcolor', + 'barcolor', + 'alertcondition', + 'indicator', + 'strategy', + 'library', +]); + +/** Names that are conventionally declared without being read. */ +const IGNORED_PREFIX = '_'; + +export function lint(source: LintSource, ref: ReferenceIndex): LintIssue[] { + const issues: LintIssue[] = [...versionIssues(source), ...tokenIssues(source, ref), ...unusedIssues(source)]; + return issues.sort((a, b) => a.line - b.line || a.startCol - b.startCol); +} + +function versionIssues(source: LintSource): LintIssue[] { + const { version } = source.model; + if (version === null) { + return [ + { + rule: 'missing-version', + line: 0, + startCol: 0, + endCol: source.lines[0]?.length ?? 0, + message: `This script has no version pragma, so TradingView compiles it as an old version. Add //@version=${TARGET_VERSION}.`, + severity: 'warning', + unnecessary: false, + fix: { + title: `Add \`//@version=${TARGET_VERSION}\``, + preferred: true, + edits: [{ startLine: 0, startCol: 0, endLine: 0, endCol: 0, newText: `//@version=${TARGET_VERSION}\n` }], + }, + }, + ]; + } + if (version >= TARGET_VERSION) return []; + const line = source.lines.findIndex((l) => /^\/\/@version\s*=/.test(l)); + const text = source.lines[line] ?? ''; + return [ + { + rule: 'old-version', + line: Math.max(0, line), + startCol: 0, + endCol: text.length, + message: `This script targets v${version}. TradingView compiles v${TARGET_VERSION}, and this extension documents v${TARGET_VERSION}.`, + severity: 'warning', + unnecessary: false, + fix: { + title: `Change the pragma to v${TARGET_VERSION}`, + preferred: true, + edits: [ + { + startLine: Math.max(0, line), + startCol: 0, + endLine: Math.max(0, line), + endCol: text.length, + newText: `//@version=${TARGET_VERSION}`, + }, + ], + }, + }, + ]; +} + +/** One pass over the tokens, covering the rules that read a call or a name in place. */ +function tokenIssues(source: LintSource, ref: ReferenceIndex): LintIssue[] { + const flat = flattenTokens(source.tokens); + const bound = boundNames(source); + const issues: LintIssue[] = []; + const seenArguments = new Map>(); + + for (let i = 0; i < flat.length; i++) { + const token = flat[i]!; + if (token.kind !== 'ident') continue; + + if (isNamedArgument(flat, i)) { + const callee = calleeOf(flat, i); + const start = callStart(flat, i); + if (start !== null) { + const used = seenArguments.get(start) ?? new Set(); + if (used.has(token.text)) issues.push(duplicateArgument(token)); + used.add(token.text); + seenArguments.set(start, used); + } + if (callee) { + const unknown = unknownArgument(source, token, callee, ref); + if (unknown) issues.push(unknown); + } + continue; + } + if (isImportPath(flat, i) || isMemberAccess(flat, i)) continue; + + if (TOP_LEVEL_ONLY.has(token.text) && flat[i + 1]?.kind === 'open') { + const local = localScopeCall(source, token); + if (local) issues.push(local); + continue; + } + + const legacy = legacyName(source, ref, token, bound); + if (legacy) issues.push(legacy); + } + return issues; +} + +function duplicateArgument(token: Token): LintIssue { + return { + rule: 'duplicate-argument', + line: token.line, + startCol: token.start, + endCol: token.end, + message: `The argument \`${token.text}\` is given twice in this call.`, + severity: 'error', + unnecessary: false, + fix: null, + }; +} + +/** Flags a named argument no overload of the built-in accepts, which is how v4 options were dropped. */ +function unknownArgument(source: LintSource, token: Token, callee: string, ref: ReferenceIndex): LintIssue | null { + const entry = ref.get(callee, 'function'); + if (!entry || !entry.overloads.length) return null; + const parameters = [...new Set(entry.overloads.flatMap((o) => o.params.map((p) => p.name)))]; + if (!parameters.length || parameters.includes(token.text)) return null; + const [closest] = nearest(token.text, parameters); + return { + rule: 'unknown-argument', + line: token.line, + startCol: token.start, + endCol: token.end, + message: closest + ? `\`${callee}()\` has no argument called \`${token.text}\`. Did you mean \`${closest}\`?` + : `\`${callee}()\` has no argument called \`${token.text}\`.`, + severity: 'warning', + unnecessary: false, + fix: closest + ? { + title: `Rename the argument to \`${closest}\``, + preferred: true, + edits: [ + { startLine: token.line, startCol: token.start, endLine: token.line, endCol: token.end, newText: closest }, + ], + } + : removalFix(source, token), + }; +} + +/** Offers to drop an argument that has no near miss, which is how options removed in v5 read. */ +function removalFix(source: LintSource, token: Token): QuickFix | null { + const span = argumentSpan(source.lines[token.line] ?? '', token.start); + if (!span) return null; + return { + title: `Remove the \`${token.text}\` argument`, + preferred: true, + edits: [{ startLine: token.line, startCol: span.start, endLine: token.line, endCol: span.end, newText: '' }], + }; +} + +function localScopeCall(source: LintSource, token: Token): LintIssue | null { + if (source.tokens[token.line]!.depthAtStart > 0) return null; + const text = source.lines[token.line] ?? ''; + if (!/^\s/.test(text)) return null; + return { + rule: 'local-scope-call', + line: token.line, + startCol: token.start, + endCol: token.end, + message: `\`${token.text}()\` only works at the top level of a script, not inside a local block.`, + severity: 'error', + unnecessary: false, + fix: null, + }; +} + +/** Flags a bare name that moved into a namespace in v5, which is the commonest v4 leftover. */ +function legacyName( + source: LintSource, + ref: ReferenceIndex, + token: Token, + bound: ReadonlySet, +): LintIssue | null { + if (token.text.includes('.') || bound.has(token.text) || ref.get(token.text)) return null; + // Only a certain replacement is worth reporting: a namespace move, or a documented rename. + const renamed = renamedTo(token.text); + const [suggested] = suggestNames(token.text, source.model, ref); + const replacement = renamed ?? (suggested?.includes('.') ? suggested : undefined); + if (!replacement) return null; + return { + rule: 'legacy-name', + line: token.line, + startCol: token.start, + endCol: token.end, + message: `\`${token.text}\` is not a v${TARGET_VERSION} name. It is \`${replacement}\` now.`, + severity: 'warning', + unnecessary: false, + fix: { + title: `Change to \`${replacement}\``, + preferred: true, + edits: [ + { startLine: token.line, startCol: token.start, endLine: token.line, endCol: token.end, newText: replacement }, + ], + }, + }; +} + +/** Declarations that are never read: variables, parameters and imports. */ +function unusedIssues(source: LintSource): LintIssue[] { + const issues: LintIssue[] = []; + const { model } = source; + + for (const variable of model.variables) { + if (variable.name.startsWith(IGNORED_PREFIX)) continue; + const at = position(source, variable.line, variable.name); + if (!at) continue; + const uses = occurrencesOf(source, { + name: variable.name, + kind: 'variable', + declaration: { ...at, isDeclaration: true }, + scope: variable.scope, + owner: null, + }); + if (uses.length <= 1) issues.push(unused('unused-variable', at, `\`${variable.name}\` is never used.`)); + } + + for (const fn of model.functions) { + for (const parameter of fn.params) { + if (parameter.name.startsWith(IGNORED_PREFIX)) continue; + const at = position(source, fn.line, parameter.name); + if (!at) continue; + const uses = occurrencesOf(source, { + name: parameter.name, + kind: 'parameter', + declaration: { ...at, isDeclaration: true }, + scope: fn.range, + owner: fn.name, + }); + if (uses.length <= 1) + issues.push(unused('unused-parameter', at, `\`${parameter.name}\` is never used in \`${fn.name}\`.`)); + } + } + + for (const imported of model.imports) { + if (!imported.alias) continue; + const at = position(source, imported.line, imported.alias); + if (!at) continue; + const uses = occurrencesOf(source, { + name: imported.alias, + kind: 'import', + declaration: { ...at, isDeclaration: true }, + scope: { start: 0, end: source.lines.length - 1 }, + owner: null, + }); + if (uses.length <= 1) issues.push(unused('unused-import', at, `\`${imported.alias}\` is imported but never used.`)); + } + return issues; +} + +function unused(rule: string, at: { line: number; startCol: number; endCol: number }, message: string): LintIssue { + return { rule, ...at, message, severity: 'hint', unnecessary: true, fix: null }; +} + +/** The range of a name on a line, taken from the tokens rather than a text search. */ +function position( + source: LintSource, + line: number, + name: string, +): { line: number; startCol: number; endCol: number } | null { + const token = source.tokens[line]?.tokens.find((t) => t.kind === 'ident' && t.text === name); + return token ? { line, startCol: token.start, endCol: token.end } : null; +} + +/** Every name the document binds, including the loop variables the model does not record. */ +function boundNames(source: LintSource): Set { + const { model } = source; + const names = new Set(); + for (const fn of model.functions) { + names.add(fn.name); + for (const parameter of fn.params) names.add(parameter.name); + } + for (const type of model.types) { + names.add(type.name); + for (const field of type.fields) names.add(field.name); + } + for (const enumeration of model.enums) { + names.add(enumeration.name); + for (const member of enumeration.members) names.add(member.name); + } + for (const variable of model.variables) names.add(variable.name); + for (const imported of model.imports) if (imported.alias) names.add(imported.alias); + for (const name of forInVariables(source.tokens)) names.add(name); + return names; +} + +/** `for name in collection` binds a name the document model does not collect. */ +function forInVariables(lines: TokenizedLine[]): string[] { + const names: string[] = []; + for (const line of lines) { + const significant = line.tokens.filter((t) => t.kind !== 'ws' && t.kind !== 'comment'); + if (significant[0]?.text !== 'for') continue; + for (let i = 1; i < significant.length; i++) { + if (significant[i]!.text !== 'in') continue; + for (let j = 1; j < i; j++) if (significant[j]!.kind === 'ident') names.push(significant[j]!.text); + break; + } + } + return names; +} + +/** The index of the `(` that opens the call a token sits in, used to group its arguments. */ +function callStart(flat: Token[], index: number): number | null { + let depth = 0; + for (let i = index - 1; i >= 0; i--) { + const token = flat[i]!; + if (token.kind === 'close') depth++; + else if (token.kind === 'open') { + if (depth === 0) return i; + depth--; + } + } + return null; +} diff --git a/src/extension/core/quick-fix.ts b/src/extension/core/quick-fix.ts index 0903967..1e79db2 100644 --- a/src/extension/core/quick-fix.ts +++ b/src/extension/core/quick-fix.ts @@ -1,6 +1,7 @@ import type { CompileDiagnostic } from './diagnostics'; import type { DocumentModel } from './document-model'; import { ReferenceIndex } from './reference'; +import { nearest } from './text'; import { tokenize, wordAt, type Token } from './tokenizer'; export interface QuickFixEdit { @@ -31,11 +32,15 @@ const CODE = { /** Names that moved somewhere other than a namespace when Pine went from v4 to v5. */ const RENAMED = new Map([['study', 'indicator']]); +/** The v6 name of a built-in that was renamed outright, rather than moved into a namespace. */ +export function renamedTo(name: string): string | undefined { + return RENAMED.get(name); +} + /** Types that can be declared with a keyword, offered when a value is `na`. */ const DECLARABLE = ['float', 'int', 'bool', 'string', 'color']; const MAX_SUGGESTIONS = 3; -const MAX_DISTANCE = 2; /** Builds the fixes offered for one compiler diagnostic. */ export function quickFixes( @@ -72,11 +77,22 @@ function renameToKnownSymbol( const name = issue.ctx?.fullName ?? issue.ctx?.identifier ?? word?.text; if (!word || !name || word.text !== name) return []; + const suggestions = suggestNames(name, model, ref); + return suggestions.slice(0, MAX_SUGGESTIONS).map((suggestion, index) => ({ + title: `Change to \`${suggestion}\``, + preferred: index === 0 && suggestions.length === 1, + edits: [ + { startLine: issue.line, startCol: word.start, endLine: issue.line, endCol: word.end, newText: suggestion }, + ], + })); +} + +/** The names worth offering in place of one the compiler does not know, best first. */ +export function suggestNames(name: string, model: DocumentModel, ref: ReferenceIndex): string[] { const suggestions: string[] = []; const add = (candidate: string) => { if (candidate !== name && !suggestions.includes(candidate)) suggestions.push(candidate); }; - const renamed = RENAMED.get(name); if (renamed) add(renamed); if (!name.includes('.')) { @@ -84,14 +100,7 @@ function renameToKnownSymbol( for (const namespace of ref.childNamespaces('')) if (ref.get(`${namespace}.${name}`)) add(`${namespace}.${name}`); } for (const candidate of nearest(name, candidateNames(name, model, ref))) add(candidate); - - return suggestions.slice(0, MAX_SUGGESTIONS).map((suggestion, index) => ({ - title: `Change to \`${suggestion}\``, - preferred: index === 0 && suggestions.length === 1, - edits: [ - { startLine: issue.line, startCol: word.start, endLine: issue.line, endCol: word.end, newText: suggestion }, - ], - })); + return suggestions; } function candidateNames(name: string, model: DocumentModel, ref: ReferenceIndex): string[] { @@ -157,7 +166,8 @@ function fixArgumentName(issue: CompileDiagnostic, lines: string[], ref: Referen * Finds the text to delete to drop a named argument, including the comma that separates it. * Returns null when the argument does not fit on the line, where a blind cut would not be safe. */ -function argumentSpan(text: string, nameStart: number): { start: number; end: number } | null { +// eslint-disable-next-line -- shared with the offline rules +export function argumentSpan(text: string, nameStart: number): { start: number; end: number } | null { const tokens = tokenize(text)[0]!.tokens.filter((t) => t.kind !== 'ws' && t.kind !== 'comment'); const index = tokens.findIndex((t) => t.start === nameStart); if (index < 0 || tokens[index + 1]?.text !== '=') return null; @@ -259,34 +269,3 @@ function addVersionPragma(issue: CompileDiagnostic, lines: string[]): QuickFix[] }, ]; } - -/** Orders candidates by how few single character edits separate them from `name`. */ -function nearest(name: string, candidates: readonly string[]): string[] { - const limit = name.length <= 4 ? 1 : MAX_DISTANCE; - const scored: { candidate: string; distance: number }[] = []; - for (const candidate of candidates) { - if (candidate === name || Math.abs(candidate.length - name.length) > limit) continue; - const distance = editDistance(name, candidate, limit); - if (distance <= limit) scored.push({ candidate, distance }); - } - scored.sort((a, b) => a.distance - b.distance || a.candidate.localeCompare(b.candidate)); - return scored.map((s) => s.candidate); -} - -/** Levenshtein distance, giving up as soon as it passes `limit`. */ -function editDistance(a: string, b: string, limit: number): number { - let previous = Array.from({ length: b.length + 1 }, (_, i) => i); - for (let i = 1; i <= a.length; i++) { - const current = [i]; - let best = i; - for (let j = 1; j <= b.length; j++) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1; - const value = Math.min(previous[j]! + 1, current[j - 1]! + 1, previous[j - 1]! + cost); - current.push(value); - if (value < best) best = value; - } - if (best > limit) return limit + 1; - previous = current; - } - return previous[b.length]!; -} diff --git a/src/extension/core/semantic.ts b/src/extension/core/semantic.ts new file mode 100644 index 0000000..9ceb074 --- /dev/null +++ b/src/extension/core/semantic.ts @@ -0,0 +1,118 @@ +import { + flattenTokens, + isImportPath, + isMemberAccess, + isNamedArgument, + resolveSymbolAt, + type SymbolSource, + type SymbolTarget, +} from './symbols'; + +/** Token types the editor understands, limited to the ones a Pine document can produce. */ +export type SemanticKind = + 'function' | 'method' | 'type' | 'enum' | 'enumMember' | 'parameter' | 'variable' | 'property' | 'namespace'; + +export interface SemanticToken { + line: number; + startCol: number; + length: number; + kind: SemanticKind; + isDeclaration: boolean; +} + +const OF_TARGET: Partial> = { + function: 'function', + method: 'method', + type: 'type', + enum: 'enum', + enumMember: 'enumMember', + parameter: 'parameter', + variable: 'variable', + import: 'namespace', +}; + +/** + * Classifies the identifiers the user declared. Built-ins are left out on purpose: the grammar + * already colours those, and only the document itself knows which names are the author's own. + */ +export function semanticTokens(source: SymbolSource): SemanticToken[] { + const flat = flattenTokens(source.tokens); + const declared = declaredNames(source); + const out: SemanticToken[] = []; + for (let i = 0; i < flat.length; i++) { + const token = flat[i]!; + if (token.kind !== 'ident') continue; + if (!declared.has(token.text.split('.')[0]!)) continue; + if (isImportPath(flat, i) || isNamedArgument(flat, i) || isMemberAccess(flat, i)) continue; + + const field = memberDeclaration(source, token.line, token.start, token.text); + if (field) { + out.push({ line: token.line, startCol: token.start, length: token.text.length, ...field }); + continue; + } + + const target = resolveSymbolAt(source, token.line, token.start); + if (!target) continue; + const kind = OF_TARGET[target.kind]; + if (!kind) continue; + // `Regime.bull` reads better coloured as one enum member than as an enum and a stray word. + const member = kind === 'enum' ? enumMemberOf(source, token.text) : null; + out.push({ + line: token.line, + startCol: token.start, + length: member ? token.text.length : target.name.length, + kind: member ? 'enumMember' : kind, + isDeclaration: target.declaration?.line === token.line && target.declaration.startCol === token.start, + }); + } + return out; +} + +/** The names the author declared, used to skip the built-ins that make up most of a script. */ +function declaredNames(source: SymbolSource): Set { + const { model } = source; + const names = new Set(); + for (const fn of model.functions) { + names.add(fn.name); + for (const param of fn.params) names.add(param.name); + } + for (const type of model.types) { + names.add(type.name); + for (const field of type.fields) names.add(field.name); + } + for (const enumeration of model.enums) { + names.add(enumeration.name); + for (const member of enumeration.members) names.add(member.name); + } + for (const variable of model.variables) names.add(variable.name); + for (const imported of model.imports) if (imported.alias) names.add(imported.alias); + return names; +} + +function enumMemberOf(source: SymbolSource, text: string): boolean { + const [name, member] = text.split('.'); + return source.model.enums.some((e) => e.name === name && e.members.some((m) => m.name === member)); +} + +/** Recognises the name of a field inside a `type` block or a member inside an `enum` block. */ +function memberDeclaration( + source: SymbolSource, + line: number, + column: number, + text: string, +): { kind: SemanticKind; isDeclaration: boolean } | null { + const type = source.model.types.find((t) => line > t.line && line <= t.range.end); + if (type) { + const idents = source.tokens[line]!.tokens.filter((t) => t.kind === 'ident'); + if (idents[1]?.start === column && type.fields.some((f) => f.name === text)) + return { kind: 'property', isDeclaration: true }; + return null; + } + const enumeration = source.model.enums.find((e) => line > e.line && line <= e.range.end); + if (enumeration) { + const idents = source.tokens[line]!.tokens.filter((t) => t.kind === 'ident'); + if (idents[0]?.start === column && enumeration.members.some((m) => m.name === text)) + return { kind: 'enumMember', isDeclaration: true }; + } + return null; +} diff --git a/src/extension/core/symbols.ts b/src/extension/core/symbols.ts new file mode 100644 index 0000000..cf3a4c2 --- /dev/null +++ b/src/extension/core/symbols.ts @@ -0,0 +1,318 @@ +import type { DocumentModel, LineRange } from './document-model'; +import { tokenAt, type Token, type TokenizedLine } from './tokenizer'; + +export type TargetKind = + 'function' | 'method' | 'type' | 'enum' | 'enumMember' | 'variable' | 'parameter' | 'import' | 'builtin'; + +export interface Occurrence { + line: number; + startCol: number; + endCol: number; + isDeclaration: boolean; +} + +export interface SymbolTarget { + /** The name being referenced: the first segment of a dotted token, or the whole built-in name. */ + name: string; + kind: TargetKind; + declaration: Occurrence | null; + /** Lines within which occurrences of this name mean this symbol. */ + scope: LineRange; + /** The function a parameter belongs to, so calls that name it can be found. */ + owner: string | null; +} + +export interface SymbolSource { + model: DocumentModel; + tokens: TokenizedLine[]; +} + +/** True for a symbol the user declared in this document, which is the only kind that can be renamed. */ +export function isUserSymbol(target: SymbolTarget): boolean { + return target.kind !== 'builtin' && target.declaration !== null; +} + +/** Works out what the identifier under the cursor refers to. */ +export function resolveSymbolAt(source: SymbolSource, line: number, column: number): SymbolTarget | null { + const tokens = source.tokens[line]?.tokens; + if (!tokens) return null; + const token = tokenAt(tokens, column); + if (!token || token.kind !== 'ident') return null; + + const segments = token.text.split('.'); + const offset = column - token.start; + const onFirstSegment = offset <= segments[0]!.length; + if (!onFirstSegment) return resolveMember(source, token, segments); + return resolveBase(source, segments[0]!, line, token, tokens); +} + +/** Resolves `Enum.member`, and treats every other dotted name as a built-in. */ +function resolveMember(source: SymbolSource, token: Token, segments: string[]): SymbolTarget | null { + const owner = source.model.enums.find((e) => e.name === segments[0]); + const member = owner?.members.find((m) => m.name === segments[1]); + if (owner && member) { + const declaration = findMemberDeclaration(source, owner.range, member.name); + return { + name: `${owner.name}.${member.name}`, + kind: 'enumMember', + declaration, + scope: wholeFile(source), + owner: owner.name, + }; + } + return { name: token.text, kind: 'builtin', declaration: null, scope: wholeFile(source), owner: null }; +} + +function resolveBase( + source: SymbolSource, + name: string, + line: number, + token: Token, + lineTokens: Token[], +): SymbolTarget { + const { model } = source; + const calling = nextSignificant(lineTokens, token)?.kind === 'open'; + + const enclosing = model.functions.find((f) => line >= f.line && line <= f.range.end); + const parameter = enclosing?.params.find((p) => p.name === name); + if (parameter && !calling) { + return { + name, + kind: 'parameter', + declaration: findParameterDeclaration(source, enclosing!.line, name), + scope: enclosing!.range, + owner: enclosing!.name, + }; + } + + const fn = model.functions.find((f) => f.name === name); + if (fn && calling) { + return { + name, + kind: fn.isMethod ? 'method' : 'function', + declaration: findNameOnLine(source, fn.line, name), + scope: wholeFile(source), + owner: null, + }; + } + + const visible = model.variables + .filter((v) => v.name === name && v.scope.start <= line && line <= v.scope.end) + .sort((a, b) => b.line - a.line); + const declared = visible[0] ?? model.variables.find((v) => v.name === name); + if (declared) { + return { + name, + kind: 'variable', + declaration: { + line: declared.line, + startCol: declared.column, + endCol: declared.column + name.length, + isDeclaration: true, + }, + scope: declared.scope, + owner: null, + }; + } + + if (fn) { + return { + name, + kind: fn.isMethod ? 'method' : 'function', + declaration: findNameOnLine(source, fn.line, name), + scope: wholeFile(source), + owner: null, + }; + } + const type = model.types.find((t) => t.name === name); + if (type) { + return { + name, + kind: 'type', + declaration: findNameOnLine(source, type.line, name), + scope: wholeFile(source), + owner: null, + }; + } + const enumeration = model.enums.find((e) => e.name === name); + if (enumeration) { + return { + name, + kind: 'enum', + declaration: findNameOnLine(source, enumeration.line, name), + scope: wholeFile(source), + owner: null, + }; + } + const imported = model.imports.find((i) => i.alias === name); + if (imported) { + return { + name, + kind: 'import', + declaration: findNameOnLine(source, imported.line, name), + scope: wholeFile(source), + owner: null, + }; + } + return { name, kind: 'builtin', declaration: null, scope: wholeFile(source), owner: null }; +} + +/** Every place the target is written, in document order. */ +export function occurrencesOf(source: SymbolSource, target: SymbolTarget): Occurrence[] { + const flat = flattenTokens(source.tokens); + const shadowed = shadowingRanges(source.model, target); + const found: Occurrence[] = []; + for (let i = 0; i < flat.length; i++) { + const token = flat[i]!; + if (token.kind !== 'ident') continue; + if (token.line < target.scope.start || token.line > target.scope.end) continue; + if (shadowed.some((r) => token.line >= r.start && token.line <= r.end)) continue; + if (!matches(token.text, target.name)) continue; + if (isImportPath(flat, i) || isMemberAccess(flat, i)) continue; + if (isMemberDeclaration(source, token, flat, i) && target.kind !== 'enumMember') continue; + if (isNamedArgument(flat, i) && !namesOwnParameter(flat, i, target)) continue; + found.push({ + line: token.line, + startCol: token.start, + endCol: token.start + target.name.length, + isDeclaration: isSamePosition(token, target.declaration), + }); + } + // An enum member is written bare inside its own block, so its declaration needs adding by hand. + const declaration = target.declaration; + if (declaration && !found.some((o) => o.line === declaration.line && o.startCol === declaration.startCol)) { + found.push(declaration); + found.sort((a, b) => a.line - b.line || a.startCol - b.startCol); + } + return found; +} + +/** + * Function bodies that declare the same name again, where the name means something else. A global + * `length` is not the `length` a function takes as a parameter. + */ +function shadowingRanges(model: DocumentModel, target: SymbolTarget): LineRange[] { + if (target.kind !== 'variable' && target.kind !== 'builtin') return []; + const declaration = target.declaration?.line ?? -1; + return model.functions + .filter((f) => !(declaration > f.line && declaration <= f.range.end)) + .filter( + (f) => + f.params.some((p) => p.name === target.name) || + model.variables.some((v) => v.name === target.name && v.line > f.line && v.line <= f.range.end), + ) + .map((f) => f.range); +} + +/** A token refers to `name` when it is the name itself or starts with it as a namespace. */ +function matches(text: string, name: string): boolean { + return text === name || text.startsWith(`${name}.`); +} + +function isSamePosition(token: Token, declaration: Occurrence | null): boolean { + return !!declaration && declaration.line === token.line && declaration.startCol === token.start; +} + +/** Every token that carries meaning, in document order, each knowing its line. */ +export function flattenTokens(lines: TokenizedLine[]): Token[] { + const flat: Token[] = []; + for (const line of lines) + for (const token of line.tokens) if (token.kind !== 'ws' && token.kind !== 'comment') flat.push(token); + return flat; +} + +function nextSignificant(tokens: Token[], after: Token): Token | undefined { + return tokens.find((t) => t.start >= after.end && t.kind !== 'ws' && t.kind !== 'comment'); +} + +/** The owner, library and version of an `import` are a path, not references to anything. */ +export function isImportPath(flat: Token[], index: number): boolean { + let i = index; + while (i >= 0 && flat[i]!.line === flat[index]!.line) i--; + const first = flat[i + 1]; + if (!first || first.text !== 'import') return false; + // Only the alias after `as` is a symbol. + for (let j = i + 1; j < index; j++) if (flat[j]!.text === 'as') return false; + return true; +} + +/** True for the name of a field inside a `type` block or a member inside an `enum` block. */ +function isMemberDeclaration(source: SymbolSource, token: Token, flat: Token[], index: number): boolean { + const inType = source.model.types.some((t) => token.line > t.line && token.line <= t.range.end); + const inEnum = source.model.enums.some((e) => token.line > e.line && token.line <= e.range.end); + if (!inType && !inEnum) return false; + const identsBefore = countIdentsBefore(flat, index); + return inEnum ? identsBefore === 0 : identsBefore === 1; +} + +function countIdentsBefore(flat: Token[], index: number): number { + let count = 0; + for (let i = index - 1; i >= 0 && flat[i]!.line === flat[index]!.line; i--) if (flat[i]!.kind === 'ident') count++; + return count; +} + +/** True when the token is the member half of `expression.member`, so it names nothing on its own. */ +export function isMemberAccess(flat: Token[], index: number): boolean { + const previous = flat[index - 1]; + return !!previous && previous.kind === 'op' && previous.text === '.'; +} + +/** True for `name` in `f(name = value)`, which is the callee's parameter rather than a local symbol. */ +export function isNamedArgument(flat: Token[], index: number): boolean { + const next = flat[index + 1]; + const previous = flat[index - 1]; + if (!next || next.text !== '=') return false; + return !!previous && (previous.kind === 'open' || previous.kind === 'comma'); +} + +/** True when a named argument belongs to the very function whose parameter is being tracked. */ +function namesOwnParameter(flat: Token[], index: number, target: SymbolTarget): boolean { + if (target.kind !== 'parameter' || !target.owner) return false; + return calleeOf(flat, index) === target.owner; +} + +/** Walks back to the call this token sits inside and returns the name being called. */ +export function calleeOf(flat: Token[], index: number): string | null { + let depth = 0; + for (let i = index - 1; i >= 0; i--) { + const token = flat[i]!; + if (token.kind === 'close') depth++; + else if (token.kind === 'open') { + if (depth === 0) return flat[i - 1]?.kind === 'ident' ? flat[i - 1]!.text : null; + depth--; + } + } + return null; +} + +function findNameOnLine(source: SymbolSource, line: number, name: string): Occurrence | null { + const token = source.tokens[line]?.tokens.find((t) => t.kind === 'ident' && t.text === name); + if (!token) return null; + return { line, startCol: token.start, endCol: token.end, isDeclaration: true }; +} + +/** Finds a parameter in the header of its function, which may wrap over several lines. */ +function findParameterDeclaration(source: SymbolSource, headerLine: number, name: string): Occurrence | null { + for (let line = headerLine; line < Math.min(source.tokens.length, headerLine + 20); line++) { + const tokens = source.tokens[line]!.tokens; + const openParen = line === headerLine ? tokens.find((t) => t.kind === 'open') : undefined; + const token = tokens.find( + (t) => t.kind === 'ident' && t.text === name && (!openParen || t.start > openParen.start), + ); + if (token) return { line, startCol: token.start, endCol: token.end, isDeclaration: true }; + if (tokens.some((t) => t.kind === 'op' && t.text === '=>')) break; + } + return null; +} + +function findMemberDeclaration(source: SymbolSource, range: LineRange, name: string): Occurrence | null { + for (let line = range.start + 1; line <= range.end; line++) { + const token = source.tokens[line]?.tokens.find((t) => t.kind === 'ident'); + if (token?.text === name) return { line, startCol: token.start, endCol: token.end, isDeclaration: true }; + } + return null; +} + +function wholeFile(source: SymbolSource): LineRange { + return { start: 0, end: Math.max(0, source.tokens.length - 1) }; +} diff --git a/src/extension/core/text.ts b/src/extension/core/text.ts new file mode 100644 index 0000000..10a9873 --- /dev/null +++ b/src/extension/core/text.ts @@ -0,0 +1,30 @@ +/** Orders candidates by how few single character edits separate them from `name`. */ +export function nearest(name: string, candidates: readonly string[], maxDistance = 2): string[] { + const limit = name.length <= 4 ? 1 : maxDistance; + const scored: { candidate: string; distance: number }[] = []; + for (const candidate of candidates) { + if (candidate === name || Math.abs(candidate.length - name.length) > limit) continue; + const distance = editDistance(name, candidate, limit); + if (distance <= limit) scored.push({ candidate, distance }); + } + scored.sort((a, b) => a.distance - b.distance || a.candidate.localeCompare(b.candidate)); + return scored.map((s) => s.candidate); +} + +/** Levenshtein distance, giving up as soon as it passes `limit`. */ +export function editDistance(a: string, b: string, limit: number): number { + let previous = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + const current = [i]; + let best = i; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + const value = Math.min(previous[j]! + 1, current[j - 1]! + 1, previous[j - 1]! + cost); + current.push(value); + if (value < best) best = value; + } + if (best > limit) return limit + 1; + previous = current; + } + return previous[b.length]!; +} diff --git a/src/extension/extension.ts b/src/extension/extension.ts index c88b06c..3d78b90 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { registerAddTypeAnnotations } from './commands/add-type-annotations'; +import { registerConvertToV6 } from './commands/convert-to-v6'; import { registerGenerateDocstring } from './commands/generate-docstring'; import { registerNewFileCommands } from './commands/new-file'; import { registerOpenReference } from './commands/open-reference'; @@ -11,7 +12,15 @@ import { PineFacade } from './core/pine-facade'; import { loadReference } from './core/reference'; import { PineCompletionProvider } from './providers/completion'; import { PineDocumentSymbolProvider } from './providers/document-symbol'; +import { PineColorProvider, PineSemanticTokensProvider, SEMANTIC_LEGEND } from './providers/decorations'; import { PineFormattingProvider } from './providers/formatting'; +import { LintController } from './providers/lint-controller'; +import { + PineDefinitionProvider, + PineDocumentHighlightProvider, + PineReferenceProvider, + PineRenameProvider, +} from './providers/navigation'; import { PineHoverProvider } from './providers/hover'; import { PineSignatureHelpProvider } from './providers/signature-help'; import { forget } from './vscode/document-cache'; @@ -28,16 +37,24 @@ export function activate(context: vscode.ExtensionContext): void { libraries.start(context); const diagnostics = new DiagnosticsController(facade, getSettings); diagnostics.start(context); + const reference = loadReference(); + const linter = new LintController(reference, getSettings); + linter.start(context); registerProviders(context, libraries); registerAddTypeAnnotations(context, (uri) => diagnostics.typesFor(uri)); registerGenerateDocstring(context); registerNewFileCommands(context); registerOpenReference(context); + registerConvertToV6(context, reference); context.subscriptions.push( vscode.languages.registerCodeActionsProvider( SELECTOR, - new PineCodeActionProvider(loadReference(), (uri) => diagnostics.issuesFor(uri)), + new PineCodeActionProvider( + reference, + (uri) => diagnostics.issuesFor(uri), + (uri) => linter.issuesFor(uri), + ), PineCodeActionProvider.metadata, ), vscode.workspace.onDidChangeConfiguration((e) => { @@ -45,6 +62,16 @@ export function activate(context: vscode.ExtensionContext): void { }), vscode.workspace.onDidCloseTextDocument((d) => forget(d.uri)), vscode.languages.registerDocumentSymbolProvider(SELECTOR, new PineDocumentSymbolProvider()), + vscode.languages.registerDefinitionProvider(SELECTOR, new PineDefinitionProvider()), + vscode.languages.registerReferenceProvider(SELECTOR, new PineReferenceProvider()), + vscode.languages.registerDocumentHighlightProvider(SELECTOR, new PineDocumentHighlightProvider()), + vscode.languages.registerRenameProvider(SELECTOR, new PineRenameProvider()), + vscode.languages.registerColorProvider(SELECTOR, new PineColorProvider(reference)), + vscode.languages.registerDocumentSemanticTokensProvider( + SELECTOR, + new PineSemanticTokensProvider(), + SEMANTIC_LEGEND, + ), ); log('Pine Script extension activated'); } diff --git a/src/extension/providers/code-action.ts b/src/extension/providers/code-action.ts index f1ab5e3..565ca27 100644 --- a/src/extension/providers/code-action.ts +++ b/src/extension/providers/code-action.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import type { CompileDiagnostic } from '../core/diagnostics'; import { needsDocstring } from '../core/docstring'; +import type { LintIssue } from '../core/lint'; import { declarationAt } from '../core/document-model'; import { quickFixes, type QuickFix } from '../core/quick-fix'; import type { ReferenceIndex } from '../core/reference'; @@ -10,6 +11,9 @@ import { analyze } from '../vscode/document-cache'; /** Looks up the compiler issues last reported for a document. */ export type IssueLookup = (uri: vscode.Uri) => readonly CompileDiagnostic[] | undefined; +/** Looks up the offline lint issues last reported for a document. */ +export type LintLookup = (uri: vscode.Uri) => readonly LintIssue[] | undefined; + export class PineCodeActionProvider implements vscode.CodeActionProvider { static readonly metadata: vscode.CodeActionProviderMetadata = { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix, vscode.CodeActionKind.Refactor], @@ -18,6 +22,7 @@ export class PineCodeActionProvider implements vscode.CodeActionProvider { constructor( private readonly ref: ReferenceIndex, private readonly issues: IssueLookup = () => undefined, + private readonly lintIssues: LintLookup = () => undefined, ) {} provideCodeActions(document: vscode.TextDocument, range: vscode.Range): vscode.CodeAction[] { @@ -32,6 +37,11 @@ export class PineCodeActionProvider implements vscode.CodeActionProvider { } } + for (const issue of this.lintIssues(document.uri) ?? []) { + if (issue.line < range.start.line || issue.line > range.end.line || !issue.fix) continue; + actions.push(toAction(issue.fix, document.uri)); + } + const decl = declarationAt(model, range.start.line); if (decl && needsDocstring(decl)) { const action = new vscode.CodeAction(`Generate docstring for ${decl.name}`, vscode.CodeActionKind.Refactor); diff --git a/src/extension/providers/decorations.ts b/src/extension/providers/decorations.ts new file mode 100644 index 0000000..6fee04a --- /dev/null +++ b/src/extension/providers/decorations.ts @@ -0,0 +1,68 @@ +import * as vscode from 'vscode'; + +import { colorPresentations, findColors, type Rgba } from '../core/colors'; +import type { ReferenceIndex } from '../core/reference'; +import { semanticTokens, type SemanticKind } from '../core/semantic'; +import { analyze } from '../vscode/document-cache'; + +/** Shows a swatch beside every colour a script names, and writes the picked colour back. */ +export class PineColorProvider implements vscode.DocumentColorProvider { + constructor(private readonly ref: ReferenceIndex) {} + + provideDocumentColors(document: vscode.TextDocument): vscode.ColorInformation[] { + return findColors(analyze(document).tokens, this.ref).map( + (spot) => + new vscode.ColorInformation( + new vscode.Range(spot.line, spot.startCol, spot.line, spot.endCol), + toVsColor(spot.color), + ), + ); + } + + provideColorPresentations(color: vscode.Color): vscode.ColorPresentation[] { + return colorPresentations(fromVsColor(color)).map((label) => new vscode.ColorPresentation(label)); + } +} + +const TOKEN_TYPES: SemanticKind[] = [ + 'namespace', + 'type', + 'enum', + 'function', + 'method', + 'property', + 'enumMember', + 'variable', + 'parameter', +]; +const TOKEN_MODIFIERS = ['declaration']; + +export const SEMANTIC_LEGEND = new vscode.SemanticTokensLegend(TOKEN_TYPES, TOKEN_MODIFIERS); + +/** + * Colours the names the author declared. Built-ins keep the colours the grammar gives them, so a + * theme that knows nothing about semantic tokens still looks right. + */ +export class PineSemanticTokensProvider implements vscode.DocumentSemanticTokensProvider { + provideDocumentSemanticTokens(document: vscode.TextDocument): vscode.SemanticTokens { + const builder = new vscode.SemanticTokensBuilder(SEMANTIC_LEGEND); + for (const token of semanticTokens(analyze(document))) { + builder.push( + token.line, + token.startCol, + token.length, + TOKEN_TYPES.indexOf(token.kind), + token.isDeclaration ? 1 : 0, + ); + } + return builder.build(); + } +} + +function toVsColor(color: Rgba): vscode.Color { + return new vscode.Color(color.red, color.green, color.blue, color.alpha); +} + +function fromVsColor(color: vscode.Color): Rgba { + return { red: color.red, green: color.green, blue: color.blue, alpha: color.alpha }; +} diff --git a/src/extension/providers/lint-controller.ts b/src/extension/providers/lint-controller.ts new file mode 100644 index 0000000..6247061 --- /dev/null +++ b/src/extension/providers/lint-controller.ts @@ -0,0 +1,92 @@ +import * as vscode from 'vscode'; + +import { lint, type LintIssue, type LintSeverity } from '../core/lint'; +import type { ReferenceIndex } from '../core/reference'; +import { analyze } from '../vscode/document-cache'; +import type { Settings } from '../vscode/settings'; + +const DEBOUNCE_MS = 300; + +const SEVERITY: Record = { + error: vscode.DiagnosticSeverity.Error, + warning: vscode.DiagnosticSeverity.Warning, + information: vscode.DiagnosticSeverity.Information, + hint: vscode.DiagnosticSeverity.Hint, +}; + +/** + * Runs the offline rules as the document changes. Nothing leaves the machine, so this is on by + * default, unlike the diagnostics that ask the TradingView compiler. + */ +export class LintController { + private readonly collection = vscode.languages.createDiagnosticCollection('pinescript-lint'); + private readonly timers = new Map>(); + private readonly issues = new Map(); + + constructor( + private readonly ref: ReferenceIndex, + private readonly settings: () => Settings, + ) {} + + start(context: vscode.ExtensionContext): void { + context.subscriptions.push( + this.collection, + vscode.workspace.onDidOpenTextDocument((d) => this.schedule(d, 0)), + vscode.workspace.onDidChangeTextDocument((e) => this.schedule(e.document, DEBOUNCE_MS)), + vscode.workspace.onDidCloseTextDocument((d) => this.forget(d)), + vscode.workspace.onDidChangeConfiguration((e) => { + if (!e.affectsConfiguration('pinescript.lint')) return; + this.collection.clear(); + this.issues.clear(); + vscode.workspace.textDocuments.forEach((d) => this.schedule(d, 0)); + }), + ); + vscode.workspace.textDocuments.forEach((d) => this.schedule(d, 0)); + } + + /** The issues last reported for a document, so the code actions can offer their fixes. */ + issuesFor(uri: vscode.Uri): readonly LintIssue[] | undefined { + return this.issues.get(uri.toString()); + } + + private forget(document: vscode.TextDocument): void { + this.collection.delete(document.uri); + this.issues.delete(document.uri.toString()); + clearTimeout(this.timers.get(document.uri.toString())); + } + + private schedule(document: vscode.TextDocument, delay: number): void { + if (document.languageId !== 'pinescript') return; + const key = document.uri.toString(); + clearTimeout(this.timers.get(key)); + if (!this.settings().lint) { + this.collection.delete(document.uri); + this.issues.delete(key); + return; + } + this.timers.set( + key, + setTimeout(() => this.run(document), delay), + ); + } + + private run(document: vscode.TextDocument): void { + if (document.isClosed) return; + const disabled = new Set(this.settings().lintDisabledRules); + const found = lint(analyze(document), this.ref).filter((issue) => !disabled.has(issue.rule)); + this.issues.set(document.uri.toString(), found); + this.collection.set(document.uri, found.map(toDiagnostic)); + } +} + +function toDiagnostic(issue: LintIssue): vscode.Diagnostic { + const diagnostic = new vscode.Diagnostic( + new vscode.Range(issue.line, issue.startCol, issue.line, issue.endCol), + issue.message, + SEVERITY[issue.severity], + ); + diagnostic.source = 'Pine Script'; + diagnostic.code = issue.rule; + if (issue.unnecessary) diagnostic.tags = [vscode.DiagnosticTag.Unnecessary]; + return diagnostic; +} diff --git a/src/extension/providers/navigation.ts b/src/extension/providers/navigation.ts new file mode 100644 index 0000000..addeab3 --- /dev/null +++ b/src/extension/providers/navigation.ts @@ -0,0 +1,91 @@ +import * as vscode from 'vscode'; + +import { isUserSymbol, occurrencesOf, resolveSymbolAt, type Occurrence, type SymbolTarget } from '../core/symbols'; +import { analyze } from '../vscode/document-cache'; + +/** Go to Definition for the functions, types, enums, variables and imports a document declares. */ +export class PineDefinitionProvider implements vscode.DefinitionProvider { + provideDefinition(document: vscode.TextDocument, position: vscode.Position): vscode.Location | null { + const target = targetAt(document, position); + if (!target?.declaration) return null; + return new vscode.Location(document.uri, rangeOf(target.declaration)); + } +} + +/** Find All References, listing the declaration alongside the uses when asked for. */ +export class PineReferenceProvider implements vscode.ReferenceProvider { + provideReferences( + document: vscode.TextDocument, + position: vscode.Position, + context: vscode.ReferenceContext, + ): vscode.Location[] { + const target = targetAt(document, position); + if (!target) return []; + return occurrencesOf(analyze(document), target) + .filter((o) => context.includeDeclaration || !o.isDeclaration) + .map((o) => new vscode.Location(document.uri, rangeOf(o))); + } +} + +/** Highlights the other places the symbol under the cursor is written. */ +export class PineDocumentHighlightProvider implements vscode.DocumentHighlightProvider { + provideDocumentHighlights(document: vscode.TextDocument, position: vscode.Position): vscode.DocumentHighlight[] { + const target = targetAt(document, position); + if (!target) return []; + return occurrencesOf(analyze(document), target).map( + (o) => + new vscode.DocumentHighlight( + rangeOf(o), + o.isDeclaration ? vscode.DocumentHighlightKind.Write : vscode.DocumentHighlightKind.Read, + ), + ); + } +} + +/** Renames a symbol the document declares. Built-ins are refused rather than half renamed. */ +export class PineRenameProvider implements vscode.RenameProvider { + prepareRename(document: vscode.TextDocument, position: vscode.Position): vscode.Range { + const target = targetAt(document, position); + if (!target) throw new Error('There is no symbol here to rename.'); + if (!isUserSymbol(target)) throw new Error(`\`${target.name}\` is part of Pine Script and cannot be renamed.`); + const word = document.getWordRangeAtPosition(position); + return word ?? rangeOf(target.declaration!); + } + + provideRenameEdits( + document: vscode.TextDocument, + position: vscode.Position, + newName: string, + ): vscode.WorkspaceEdit | null { + const target = targetAt(document, position); + if (!target || !isUserSymbol(target)) return null; + const replacement = target.kind === 'enumMember' ? qualify(target, newName) : newName; + const edit = new vscode.WorkspaceEdit(); + for (const occurrence of occurrencesOf(analyze(document), target)) { + edit.replace(document.uri, rangeOf(occurrence), textFor(target, occurrence, replacement, newName)); + } + return edit; + } +} + +/** An enum member is written `Enum.member`, so a new name keeps the enum in front of it. */ +function qualify(target: SymbolTarget, newName: string): string { + return newName.includes('.') ? newName : `${target.owner}.${newName}`; +} + +/** + * The declaration of an enum member is written bare inside its own block, while every use carries + * the enum in front of it. + */ +function textFor(target: SymbolTarget, occurrence: Occurrence, replacement: string, bare: string): string { + if (target.kind !== 'enumMember') return replacement; + return occurrence.isDeclaration ? bare : replacement; +} + +function targetAt(document: vscode.TextDocument, position: vscode.Position): SymbolTarget | null { + return resolveSymbolAt(analyze(document), position.line, position.character); +} + +function rangeOf(occurrence: Occurrence): vscode.Range { + return new vscode.Range(occurrence.line, occurrence.startCol, occurrence.line, occurrence.endCol); +} diff --git a/src/extension/vscode/settings.ts b/src/extension/vscode/settings.ts index 955aa2d..1cd0d05 100644 --- a/src/extension/vscode/settings.ts +++ b/src/extension/vscode/settings.ts @@ -8,6 +8,8 @@ export interface Settings { librariesRemote: boolean; diagnosticsRemote: boolean; format: boolean; + lint: boolean; + lintDisabledRules: string[]; } export function getSettings(): Settings { @@ -20,5 +22,7 @@ export function getSettings(): Settings { librariesRemote: c.get('libraries.remote', true), diagnosticsRemote: c.get('diagnostics.remote', false), format: c.get('format.enabled', true), + lint: c.get('lint.enabled', true), + lintDisabledRules: c.get('lint.disabledRules', []), }; } diff --git a/tests/core/colors.test.ts b/tests/core/colors.test.ts new file mode 100644 index 0000000..96add04 --- /dev/null +++ b/tests/core/colors.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { colorPresentations, findColors, type Rgba } from '../../src/extension/core/colors'; +import { loadReference } from '../../src/extension/core/reference'; +import { tokenize } from '../../src/extension/core/tokenizer'; + +const ref = loadReference(); +const spots = (text: string) => findColors(tokenize(text), ref); +const hex = (color: Rgba) => + [color.red, color.green, color.blue] + .map((c) => + Math.round(c * 255) + .toString(16) + .padStart(2, '0') + .toUpperCase(), + ) + .join(''); + +describe('finding colours', () => { + it('reads a hex literal', () => { + const [spot] = spots('c = #FF9800'); + expect(spot).toMatchObject({ line: 0, startCol: 4, endCol: 11 }); + expect(hex(spot!.color)).toBe('FF9800'); + expect(spot!.color.alpha).toBe(1); + }); + + it('reads the alpha pair of a hex literal', () => { + const [spot] = spots('c = #FF980080'); + expect(spot!.color.alpha).toBeCloseTo(128 / 255, 3); + }); + + it('reads a built-in colour constant from the reference', () => { + const [spot] = spots('plot(close, color = color.red)'); + expect(hex(spot!.color)).toBe('F23645'); + expect(spot).toMatchObject({ startCol: 20, endCol: 29 }); + }); + + it('applies the transparency of color.new', () => { + const [spot] = spots('c = color.new(color.blue, 25)'); + expect(hex(spot!.color)).toBe('2962FF'); + expect(spot!.color.alpha).toBeCloseTo(0.75, 5); + expect(spot).toMatchObject({ startCol: 4, endCol: 29 }); + }); + + it('accepts named arguments in color.new', () => { + const [spot] = spots('c = color.new(color = color.lime, transp = 50)'); + expect(hex(spot!.color)).toBe('00E676'); + expect(spot!.color.alpha).toBeCloseTo(0.5, 5); + }); + + it('reads color.rgb with and without transparency', () => { + expect(hex(spots('c = color.rgb(255, 152, 0)')[0]!.color)).toBe('FF9800'); + expect(spots('c = color.rgb(255, 152, 0, 40)')[0]!.color.alpha).toBeCloseTo(0.6, 5); + }); + + it('reports one swatch for a call, not one per argument', () => { + expect(spots('c = color.new(color.red, 50)')).toHaveLength(1); + }); + + it('falls back to the inner constant when the call is not literal', () => { + const found = spots('c = color.new(color.red, transparency)'); + expect(found).toHaveLength(1); + expect(found[0]).toMatchObject({ startCol: 14, endCol: 23 }); + }); + + it('ignores colours inside strings and comments', () => { + expect(spots('s = "#FF9800" // color.red')).toEqual([]); + }); + + it('finds colours on every line', () => { + expect(spots('a = #FFFFFF\nb = color.teal').map((s) => s.line)).toEqual([0, 1]); + }); +}); + +describe('writing a picked colour back', () => { + it('offers a hex literal and an rgb call', () => { + expect(colorPresentations({ red: 1, green: 0.596, blue: 0, alpha: 1 })).toEqual([ + '#FF9800', + 'color.rgb(255, 152, 0)', + ]); + }); + + it('carries transparency into both forms', () => { + expect(colorPresentations({ red: 1, green: 0, blue: 0, alpha: 0.5 })).toEqual([ + '#FF000080', + 'color.rgb(255, 0, 0, 50)', + ]); + }); +}); diff --git a/tests/core/fake-vscode.ts b/tests/core/fake-vscode.ts index f2c1f43..dd4ceba 100644 --- a/tests/core/fake-vscode.ts +++ b/tests/core/fake-vscode.ts @@ -159,6 +159,56 @@ export class CodeAction { public kind?: CodeActionKind, ) {} } +export class Location { + constructor( + public uri: Uri, + public range: Range, + ) {} +} +export enum DocumentHighlightKind { + Text, + Read, + Write, +} +export class DocumentHighlight { + constructor( + public range: Range, + public kind?: DocumentHighlightKind, + ) {} +} +export class Color { + constructor( + public red: number, + public green: number, + public blue: number, + public alpha: number, + ) {} +} +export class ColorInformation { + constructor( + public range: Range, + public color: Color, + ) {} +} +export class ColorPresentation { + constructor(public label: string) {} +} +export class SemanticTokensLegend { + constructor( + public tokenTypes: string[], + public tokenModifiers: string[], + ) {} +} +export class SemanticTokensBuilder { + readonly pushes: { line: number; char: number; length: number; type: number; modifiers: number }[] = []; + constructor(public legend?: SemanticTokensLegend) {} + push(line: number, char: number, length: number, type: number, modifiers: number) { + this.pushes.push({ line, char, length, type, modifiers }); + } + build() { + return { pushes: this.pushes }; + } +} export class Uri { private constructor(public readonly path: string) {} static parse(s: string) { @@ -183,5 +233,14 @@ export function makeDocument(text: string, uri = `file:///doc${nextVersion}.pine isClosed: false, getText: () => text, lineAt: (line: number) => ({ text: lines[line] ?? '' }), + getWordRangeAtPosition: (position: Position) => { + const text = lines[position.line] ?? ''; + let start = position.character; + let end = position.character; + const isWord = (c: string) => /[A-Za-z0-9_]/.test(c); + while (start > 0 && isWord(text[start - 1]!)) start--; + while (end < text.length && isWord(text[end]!)) end++; + return start === end ? undefined : new Range(position.line, start, position.line, end); + }, }; } diff --git a/tests/core/lint.test.ts b/tests/core/lint.test.ts new file mode 100644 index 0000000..2e1427f --- /dev/null +++ b/tests/core/lint.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { buildModel } from '../../src/extension/core/document-model'; +import { lint, type LintIssue } from '../../src/extension/core/lint'; +import { loadReference } from '../../src/extension/core/reference'; +import { tokenize } from '../../src/extension/core/tokenizer'; + +const ref = loadReference(); + +function check(text: string): LintIssue[] { + return lint({ model: buildModel(text), tokens: tokenize(text), lines: text.split('\n') }, ref); +} + +const rules = (text: string) => check(text).map((i) => i.rule); +const only = (text: string, rule: string) => check(text).filter((i) => i.rule === rule); + +/** Applies an issue's fix so the test can assert on the resulting line. */ +function applyFix(text: string, issue: LintIssue): string { + const lines = text.split('\n'); + for (const edit of [...issue.fix!.edits].sort((a, b) => b.startLine - a.startLine || b.startCol - a.startCol)) { + const line = lines[edit.startLine]!; + lines[edit.startLine] = line.slice(0, edit.startCol) + edit.newText + line.slice(edit.endCol); + } + return lines.join('\n'); +} + +describe('version rules', () => { + it('asks for a pragma when there is none', () => { + const [issue] = only('indicator("t")\nplot(close)', 'missing-version'); + expect(issue!.severity).toBe('warning'); + expect(applyFix('indicator("t")\nplot(close)', issue!)).toBe('//@version=6\nindicator("t")\nplot(close)'); + }); + + it('flags an older pragma and offers to raise it', () => { + const source = '//@version=5\nindicator("t")\nplot(close)'; + const [issue] = only(source, 'old-version'); + expect(issue!.message).toContain('v5'); + expect(applyFix(source, issue!)).toContain('//@version=6'); + }); + + it('says nothing about a current pragma', () => { + expect(rules('//@version=6\nindicator("t")\nplot(close)')).not.toContain('old-version'); + }); +}); + +describe('names left over from older versions', () => { + const header = '//@version=6\nindicator("t")\n'; + + it('points a bare v4 built-in at its namespace', () => { + const source = `${header}plot(sma(close, 14))`; + const [issue] = only(source, 'legacy-name'); + expect(issue!.message).toContain('ta.sma'); + expect(applyFix(source, issue!)).toContain('plot(ta.sma(close, 14))'); + }); + + it('leaves current names, namespaced names and keywords alone', () => { + expect(rules(`${header}plot(ta.sma(close, 14))`)).not.toContain('legacy-name'); + expect(rules(`${header}plot(close)`)).not.toContain('legacy-name'); + expect(rules(`${header}if close > open\n x = 1\nplot(close)`)).not.toContain('legacy-name'); + }); + + it('leaves a name the document declares alone', () => { + expect(rules(`${header}sma = 1\nplot(sma)`)).not.toContain('legacy-name'); + }); + + it('leaves a loop variable alone', () => { + expect( + rules(`${header}total = 0.0\nfor security in array.from(close)\n total += security\nplot(total)`), + ).not.toContain('legacy-name'); + }); +}); + +describe('calls in the wrong place', () => { + const header = '//@version=6\nindicator("t")\n'; + + it('flags a plot inside a local block', () => { + const [issue] = only(`${header}if close > open\n plot(close)`, 'local-scope-call'); + expect(issue!.severity).toBe('error'); + expect(issue!.message).toContain('top level'); + }); + + it('accepts a plot at the top level, wrapped or not', () => { + expect(rules(`${header}plot(close)`)).not.toContain('local-scope-call'); + expect(rules(`${header}plot(close,\n color = color.red)`)).not.toContain('local-scope-call'); + }); + + it('accepts calls that are allowed in a local block', () => { + expect(rules(`${header}if close > open\n alert("hi")\nplot(close)`)).not.toContain('local-scope-call'); + }); +}); + +describe('arguments', () => { + const header = '//@version=6\nindicator("t")\n'; + + it('flags the same argument given twice', () => { + expect(rules(`${header}plot(close, title = "a", title = "b")`)).toContain('duplicate-argument'); + }); + + it('flags an argument the function does not take, and suggests the near miss', () => { + const source = `${header}plot(close, titel = "a")`; + const [issue] = only(source, 'unknown-argument'); + expect(issue!.message).toContain('title'); + expect(applyFix(source, issue!)).toContain('title = "a"'); + }); + + it('flags an argument that was dropped between versions', () => { + const [issue] = only(`${header}plot(close, transp = 40)`, 'unknown-argument'); + expect(issue!.message).toContain('transp'); + }); + + it('accepts the arguments a function does take', () => { + expect(rules(`${header}plot(close, title = "a", linewidth = 2)`)).not.toContain('unknown-argument'); + }); + + it('says nothing about arguments of a user function', () => { + expect(rules(`${header}f(a) =>\n a\nplot(f(a = close))`)).not.toContain('unknown-argument'); + }); +}); + +describe('declarations that are never read', () => { + const header = '//@version=6\nindicator("t")\n'; + + it('flags an unused variable as dead code', () => { + const [issue] = only(`${header}unusedOne = 1\nplot(close)`, 'unused-variable'); + expect(issue).toMatchObject({ severity: 'hint', unnecessary: true, line: 2, startCol: 0 }); + }); + + it('flags an unused parameter and an unused import', () => { + expect(rules(`${header}f(a, b) =>\n a\nplot(f(close, open))`)).toContain('unused-parameter'); + expect(rules('//@version=6\nimport someone/utils/3 as helper\nindicator("t")\nplot(close)')).toContain( + 'unused-import', + ); + }); + + it('keeps quiet about names that are used', () => { + expect(rules(`${header}length = 14\nplot(ta.sma(close, length))`)).not.toContain('unused-variable'); + expect( + rules('//@version=6\nimport someone/utils/3 as helper\nindicator("t")\nplot(helper.f(close))'), + ).not.toContain('unused-import'); + }); + + it('respects the underscore convention', () => { + expect(rules(`${header}_scratch = 1\nplot(close)`)).not.toContain('unused-variable'); + }); +}); diff --git a/tests/core/providers.test.ts b/tests/core/providers.test.ts index 7652283..8b554b1 100644 --- a/tests/core/providers.test.ts +++ b/tests/core/providers.test.ts @@ -6,7 +6,14 @@ import { loadReference } from '../../src/extension/core/reference'; import { PineCodeActionProvider } from '../../src/extension/providers/code-action'; import { PineCompletionProvider } from '../../src/extension/providers/completion'; import { PineDocumentSymbolProvider } from '../../src/extension/providers/document-symbol'; +import { PineColorProvider, PineSemanticTokensProvider } from '../../src/extension/providers/decorations'; import { PineFormattingProvider } from '../../src/extension/providers/formatting'; +import { + PineDefinitionProvider, + PineDocumentHighlightProvider, + PineReferenceProvider, + PineRenameProvider, +} from '../../src/extension/providers/navigation'; import { PineHoverProvider } from '../../src/extension/providers/hover'; import { PineSignatureHelpProvider } from '../../src/extension/providers/signature-help'; import { docstringEdit } from '../../src/extension/commands/generate-docstring'; @@ -215,4 +222,82 @@ describe('code action provider: compiler quick fixes', () => { }); }); +const navigable = [ + '//@version=6', + 'indicator("Demo")', + 'enum Regime', + ' bull = "Bull"', + 'mean(source) =>', + ' ta.sma(source, 14)', + 'value = mean(close)', + 'mode = Regime.bull', + 'plot(value, color = color.new(color.red, 40))', +].join('\n'); + +describe('navigation providers', () => { + const d = () => doc(navigable); + + it('goes to the declaration of a user function', () => { + const location = new PineDefinitionProvider().provideDefinition(d(), new vscode.Position(6, 8)); + expect(location?.range.start.line).toBe(4); + }); + + it('has no definition for a built-in', () => { + expect(new PineDefinitionProvider().provideDefinition(d(), new vscode.Position(6, 13))).toBeNull(); + }); + + it('lists references with and without the declaration', () => { + const provider = new PineReferenceProvider(); + const withDeclaration = provider.provideReferences(d(), new vscode.Position(4, 0), { includeDeclaration: true }); + expect(withDeclaration.map((l) => l.range.start.line)).toEqual([4, 6]); + const uses = provider.provideReferences(d(), new vscode.Position(4, 0), { includeDeclaration: false }); + expect(uses.map((l) => l.range.start.line)).toEqual([6]); + }); + + it('highlights the declaration as a write and the uses as reads', () => { + const highlights = new PineDocumentHighlightProvider().provideDocumentHighlights(d(), new vscode.Position(4, 0)); + expect(highlights.map((h) => h.kind)).toEqual([ + vscode.DocumentHighlightKind.Write, + vscode.DocumentHighlightKind.Read, + ]); + }); + + it('renames a user symbol everywhere it appears', () => { + const edit = new PineRenameProvider().provideRenameEdits(d(), new vscode.Position(4, 0), 'average'); + const edits = (edit as unknown as { edits: { range: vscode.Range; newText: string }[] }).edits; + expect(edits.map((e) => `${e.range.start.line}:${e.newText}`)).toEqual(['4:average', '6:average']); + }); + + it('keeps the enum in front of a renamed member', () => { + const edit = new PineRenameProvider().provideRenameEdits(d(), new vscode.Position(7, 18), 'rising'); + const edits = (edit as unknown as { edits: { range: vscode.Range; newText: string }[] }).edits; + expect(edits.map((e) => e.newText)).toEqual(['rising', 'Regime.rising']); + }); + + it('refuses to rename a built-in', () => { + expect(() => new PineRenameProvider().prepareRename(d(), new vscode.Position(6, 13))).toThrow(/cannot be renamed/); + }); +}); + +describe('colour and semantic token providers', () => { + it('offers a swatch for a colour call and writes the picked colour back', () => { + const provider = new PineColorProvider(ref); + const colors = provider.provideDocumentColors(doc(navigable)); + expect(colors).toHaveLength(1); + expect(colors[0]!.range.start.line).toBe(8); + expect(colors[0]!.color.alpha).toBeCloseTo(0.6, 5); + expect(provider.provideColorPresentations(new vscode.Color(1, 0, 0, 1)).map((p) => p.label)).toEqual([ + '#FF0000', + 'color.rgb(255, 0, 0)', + ]); + }); + + it('emits semantic tokens only for the names the document declares', () => { + const built = new PineSemanticTokensProvider().provideDocumentSemanticTokens(doc(navigable)); + const pushes = (built as unknown as { pushes: { line: number; type: number; modifiers: number }[] }).pushes; + expect(pushes.map((p) => p.line)).toEqual([2, 3, 4, 4, 5, 6, 6, 7, 7, 8]); + expect(pushes[0]!.modifiers).toBe(1); + }); +}); + void noLibraries; diff --git a/tests/core/semantic.test.ts b/tests/core/semantic.test.ts new file mode 100644 index 0000000..3d865a8 --- /dev/null +++ b/tests/core/semantic.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { buildModel } from '../../src/extension/core/document-model'; +import { semanticTokens } from '../../src/extension/core/semantic'; +import { tokenize } from '../../src/extension/core/tokenizer'; + +const script = [ + '//@version=6', // 0 + 'import someone/utils/3 as helper', // 1 + 'indicator("Demo")', // 2 + 'type Point', // 3 + ' float price', // 4 + 'enum Regime', // 5 + ' bull = "Bull"', // 6 + 'length = input.int(14, title = "L")', // 7 + 'mean(source) =>', // 8 + ' ta.sma(source, length)', // 9 + 'value = mean(close)', // 10 + 'mode = Regime.bull', // 11 + 'plot(value, title = "V")', // 12 + 'p = helper.wrap(close)', // 13 +].join('\n'); + +const tokens = semanticTokens({ model: buildModel(script), tokens: tokenize(script) }); +const at = (line: number, startCol: number) => tokens.find((t) => t.line === line && t.startCol === startCol); + +describe('semantic tokens', () => { + it('marks the author’s own declarations', () => { + expect(at(3, 5)).toMatchObject({ kind: 'type', isDeclaration: true }); + expect(at(5, 5)).toMatchObject({ kind: 'enum', isDeclaration: true }); + expect(at(8, 0)).toMatchObject({ kind: 'function', isDeclaration: true }); + expect(at(7, 0)).toMatchObject({ kind: 'variable', isDeclaration: true }); + }); + + it('marks fields and enum members', () => { + expect(at(4, 10)).toMatchObject({ kind: 'property', isDeclaration: true }); + expect(at(6, 4)).toMatchObject({ kind: 'enumMember', isDeclaration: true }); + }); + + it('marks a parameter apart from a global of another name', () => { + expect(at(8, 5)).toMatchObject({ kind: 'parameter', isDeclaration: true }); + expect(at(9, 11)).toMatchObject({ kind: 'parameter', isDeclaration: false }); + expect(at(9, 19)).toMatchObject({ kind: 'variable', isDeclaration: false }); + }); + + it('covers a qualified enum member in one token', () => { + expect(at(11, 7)).toMatchObject({ kind: 'enumMember', length: 11 }); + }); + + it('marks an import alias as a namespace and leaves the path alone', () => { + expect(at(1, 26)).toMatchObject({ kind: 'namespace', isDeclaration: true }); + expect(at(1, 7)).toBeUndefined(); + expect(at(13, 4)).toMatchObject({ kind: 'namespace', length: 6 }); + }); + + it('leaves built-ins and named arguments to the grammar', () => { + expect(at(9, 4)).toBeUndefined(); // ta.sma + expect(at(12, 0)).toBeUndefined(); // plot + expect(at(12, 12)).toBeUndefined(); // title = + expect(at(7, 23)).toBeUndefined(); // title = inside input.int + }); + + it('marks a call to a user function', () => { + expect(at(10, 8)).toMatchObject({ kind: 'function', isDeclaration: false, length: 4 }); + }); +}); diff --git a/tests/core/symbols.test.ts b/tests/core/symbols.test.ts new file mode 100644 index 0000000..4ae977f --- /dev/null +++ b/tests/core/symbols.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; + +import { buildModel } from '../../src/extension/core/document-model'; +import { isUserSymbol, occurrencesOf, resolveSymbolAt, type SymbolSource } from '../../src/extension/core/symbols'; +import { tokenize } from '../../src/extension/core/tokenizer'; + +const script = [ + '//@version=6', // 0 + 'import someone/utils/3 as helper', // 1 + 'indicator("Demo", overlay = true)', // 2 + '', // 3 + 'type Point', // 4 + ' float price', // 5 + ' int index', // 6 + '', // 7 + 'enum Regime', // 8 + ' bull = "Bull"', // 9 + ' bear = "Bear"', // 10 + '', // 11 + 'length = input.int(14, title = "Length")', // 12 + 'price = close', // 13 + '', // 14 + 'mean(source, length) =>', // 15 + ' ta.sma(source, length)', // 16 + '', // 17 + 'method label(Point this) =>', // 18 + ' str.tostring(this.price)', // 19 + '', // 20 + 'value = mean(close, length)', // 21 + 'current = Regime.bull', // 22 + 'plot(value, title = "Value")', // 23 + 'plot(helper.smooth(price), title = "Smoothed")', // 24 +].join('\n'); + +const source: SymbolSource = { model: buildModel(script), tokens: tokenize(script) }; +const at = (line: number, column: number) => resolveSymbolAt(source, line, column); +const places = (line: number, column: number) => { + const target = at(line, column); + return target ? occurrencesOf(source, target).map((o) => `${o.line}:${o.startCol}`) : null; +}; + +describe('resolving the symbol under the cursor', () => { + it('finds a user function at its call site', () => { + const target = at(21, 8)!; + expect(target.kind).toBe('function'); + expect(target.declaration).toMatchObject({ line: 15, startCol: 0 }); + }); + + it('finds a method declaration', () => { + expect(at(18, 7)).toMatchObject({ name: 'label', kind: 'method' }); + }); + + it('finds a type and an enum', () => { + expect(at(4, 5)).toMatchObject({ name: 'Point', kind: 'type' }); + expect(at(8, 5)).toMatchObject({ name: 'Regime', kind: 'enum' }); + }); + + it('finds an enum member through its qualified name', () => { + const target = at(22, 17)!; + expect(target).toMatchObject({ name: 'Regime.bull', kind: 'enumMember' }); + expect(target.declaration).toMatchObject({ line: 9, startCol: 4 }); + }); + + it('finds an import alias, not the path around it', () => { + expect(at(1, 26)).toMatchObject({ name: 'helper', kind: 'import' }); + expect(at(1, 8)).toMatchObject({ kind: 'builtin' }); + }); + + it('prefers the parameter over the global of the same name inside a function', () => { + const target = at(16, 19)!; + expect(target).toMatchObject({ kind: 'parameter', name: 'length', owner: 'mean' }); + expect(target.scope).toMatchObject({ start: 15 }); + }); + + it('treats a built-in as a symbol without a declaration', () => { + const target = at(21, 13)!; + expect(target).toMatchObject({ name: 'close', kind: 'builtin', declaration: null }); + expect(isUserSymbol(target)).toBe(false); + }); + + it('resolves a namespaced built-in as a whole', () => { + expect(at(16, 8)).toMatchObject({ name: 'ta.sma', kind: 'builtin' }); + }); + + it('ignores a position that is not an identifier', () => { + expect(at(23, 11)).toBeNull(); + }); +}); + +describe('finding every occurrence', () => { + it('collects a function declaration and its calls', () => { + expect(places(15, 0)).toEqual(['15:0', '21:8']); + }); + + it('keeps a global out of the function that redeclares its name', () => { + // `length` is global on line 12, but a parameter of `mean`, so lines 15 and 16 are not the global. + expect(places(12, 0)).toEqual(['12:0', '21:20']); + }); + + it('keeps a parameter inside its own function', () => { + expect(places(16, 19)).toEqual(['15:13', '16:19']); + }); + + it('skips a named argument that happens to share the name', () => { + // `price` is a variable and also a field of Point; neither is the `title` argument. + expect(places(13, 0)).toEqual(['13:0', '24:19']); + }); + + it('skips the field declared inside a type block', () => { + expect(places(13, 0)).not.toContain('5:10'); + }); + + it('rewrites only the first segment of a qualified name', () => { + expect(places(8, 5)).toEqual(['8:5', '22:10']); + const target = at(8, 5)!; + const [, use] = occurrencesOf(source, target); + expect(use).toMatchObject({ line: 22, startCol: 10, endCol: 16 }); + }); + + it('collects an enum member declaration together with its uses', () => { + expect(places(22, 17)).toEqual(['9:4', '22:10']); + }); + + it('leaves the import path alone while following the alias', () => { + expect(places(1, 26)).toEqual(['1:26', '24:5']); + }); + + it('finds every use of a built-in in the document', () => { + expect(places(21, 13)).toEqual(['13:8', '21:13']); + }); +}); diff --git a/themes/pine-dark-color-theme.json b/themes/pine-dark-color-theme.json index 409ec3d..e2ba1b3 100644 --- a/themes/pine-dark-color-theme.json +++ b/themes/pine-dark-color-theme.json @@ -2,7 +2,7 @@ "$schema": "vscode://schemas/color-theme", "name": "Pine Dark", "type": "dark", - "semanticHighlighting": false, + "semanticHighlighting": true, "colors": { "editor.background": "#131722", "editor.foreground": "#d1d4dc", @@ -74,6 +74,20 @@ "terminal.background": "#131722", "terminal.foreground": "#d1d4dc" }, + "semanticTokenColors": { + "namespace": "#ffcb6b", + "type": "#ffcb6b", + "enum": "#ffcb6b", + "enumMember": "#f78c6c", + "function": "#82b1ff", + "method": "#82b1ff", + "property": "#d1d4dc", + "variable": "#d1d4dc", + "parameter": { + "foreground": "#eeffff", + "fontStyle": "italic" + } + }, "tokenColors": [ { "name": "Comments", diff --git a/themes/pine-light-color-theme.json b/themes/pine-light-color-theme.json index 94fb3df..05c4b98 100644 --- a/themes/pine-light-color-theme.json +++ b/themes/pine-light-color-theme.json @@ -2,7 +2,7 @@ "$schema": "vscode://schemas/color-theme", "name": "Pine Light", "type": "light", - "semanticHighlighting": false, + "semanticHighlighting": true, "colors": { "editor.background": "#ffffff", "editor.foreground": "#131722", @@ -74,6 +74,20 @@ "terminal.background": "#ffffff", "terminal.foreground": "#131722" }, + "semanticTokenColors": { + "namespace": "#b26a00", + "type": "#b26a00", + "enum": "#b26a00", + "enumMember": "#c2410c", + "function": "#1e88e5", + "method": "#1e88e5", + "property": "#131722", + "variable": "#131722", + "parameter": { + "foreground": "#37474f", + "fontStyle": "italic" + } + }, "tokenColors": [ { "name": "Comments",