diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3900cdc..3301c91 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
## [Unreleased]
+## [3.2.0] - 2026-09-08
+
+### Added
+
+- A formatter behind **Format Document** and **Format Selection**. It indents local blocks with the four spaces the compiler requires, repairs two space and mixed tab indentation, keeps wrapped lines aligned while moving them off a block indent, spaces operators and arguments, and leaves strings, comments and import paths untouched. Lines are never joined or split.
+- `pinescript.format.enabled`, and `editor.defaultFormatter` for `[pinescript]`, so formatting works without picking a formatter.
+- Quick fixes on compiler diagnostics: move a v4 built-in into its namespace, rename `study` to `indicator`, correct a misspelt name against the reference or the document, correct or remove a named argument, add the type keyword an `na` initialiser needs, widen a declared type, rename a variable that shadows a built-in throughout the file, and add a missing `//@version=6`.
+- Tests for the formatter and the quick fixes, and a check that every snippet and fixture survives a formatting round trip unchanged.
+
+### Changed
+
+- Compiler diagnostics keep the placeholder values the compiler sends, which is what the quick fixes read.
+
## [3.1.0] - 2026-09-08
### Added
diff --git a/README.md b/README.md
index d2c3581..2dfc119 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
Completion, hover documentation, signature help, 97 snippets, themes and compiler diagnostics for TradingView Pine Script® v6 in Visual Studio Code.
+Completion, hover documentation, signature help, formatting, quick fixes, 97 snippets and themes for TradingView Pine Script® v6 in Visual Studio Code.
@@ -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, 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, 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.
## Quick start
@@ -30,7 +30,8 @@ Open a `.pine` file and the editor already knows the language: every v6 built-in
3. Type `ta.` to see the whole namespace with documentation, pick `ta.sma`, and watch the parameter hints as you write the arguments.
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 F1 and type `Pine Script:` to see the commands: new files from templates, docstrings, type annotations and the reference.
+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.
## Features
@@ -73,13 +74,43 @@ A few worth trying first:
- `table` creates a table once and fills it on the last bar; `debug` prints any value in a label on the last bar.
- `section`, `notes` and `date` keep long scripts readable; `docfn` and `doctype` write the `//@` blocks the outline and hover use.
+### Formatting that respects how Pine reads a file
+
+**Format Document** and **Format Selection** work out of the box; turn on `editor.formatOnSave` if you want it automatic.
+
+Pine is indentation sensitive, so the formatter is deliberately conservative: it never joins or splits a line, and it never moves code between lines. What it does do:
+
+- Indents every local block with exactly four spaces per level, the only width the compiler accepts. A two space block, or a file that mixes tabs and spaces, comes out correct. If your editor is set to tabs, it indents with tabs instead.
+- Keeps a wrapped line aligned where you put it, and shifts it off a block indent when Pine would otherwise read it as a new block. Wrapping a long `strategy()` call across ten lines stays exactly as you aligned it.
+- Puts single spaces around operators and after commas, removes them inside brackets, and keeps `close[1]`, `ta.sma`, `array.new()` and `import user/lib/1` tight.
+- Leaves strings, comments and triple-quoted blocks byte for byte alone, including the gap in front of a trailing comment.
+- Trims trailing whitespace, caps runs of blank lines at two, and ends the file with a single newline.
+
+Padding used to align a column of assignments is collapsed to one space, which is the one change that is a matter of taste.
+
+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.
+
### Structure, libraries and diagnostics
- **Outline.** Functions, methods, types with fields, enums with members and top-level variables in the Outline view and breadcrumbs.
- **Libraries.** Workspace files that call `library()` are indexed: their exports complete after the import alias and show up on hover. With `pinescript.libraries.remote` on, `import` completion also lists published TradingView libraries and hover shows their exports.
- **Compiler diagnostics (opt-in).** Set `pinescript.diagnostics.remote` to `true` to send the document to the TradingView compiler and see its errors and warnings inline, on open, on save and shortly after you stop typing.
+- **Quick fixes.** With diagnostics on, the lightbulb offers a fix for the mistakes the compiler reports most: a v4 name that moved into a namespace (`sma` → `ta.sma`, `security` → `request.security`), `study` → `indicator`, a misspelt built-in or one of your own names, a misspelt or removed argument (`titel` → `title`, drop `transp`), a variable initialised with `na` that needs a type keyword, a declared type that is too narrow, a name that hides a built-in (renamed everywhere at once), and a missing `//@version=6`.
- **Commands.** New Indicator / Strategy / Library from a template, Generate Docstring (also a lightbulb on declarations), Add Type Annotations for untyped declarations, Open Reference for the built-in under the cursor.
+Bringing an old script forward is mostly clicking the lightbulb. This one goes from eight compiler complaints to a clean compile:
+
+```pine
+study("Legacy", overlay=true) //@version=6
+ma = sma(src, len) indicator("Legacy", overlay = true)
+daily = security(tickerid, "D", close) ma = ta.sma(src, len)
+int slow = ta.ema(close, 50) ==> daily = request.security(tickerid, "D", close)
+open = 1 float slow = ta.ema(close, 50)
+holder = na openValue = 1
+plot(ma, color=color.blue, transp=40) float holder = na
+ plot(ma, color = color.blue)
+```
+
### Highlighting and themes
- A grammar generated from the v6 reference: every namespace, keyword, annotation, triple-quoted string, format placeholder and hex color gets its own scope, so any theme works and the two bundled ones shine.
@@ -113,6 +144,7 @@ The grammar and the documentation data are generated from the v6 reference, so v
| `pinescript.libraries.local.include` | `**/*.pine` | Glob for workspace library discovery |
| `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 |
## Privacy
@@ -140,9 +172,10 @@ src/
reference.json full v6 documentation, generated by scripts/scrape-reference.mjs
extension/
core/ pure TypeScript: tokenizer, document model, completion context,
- type inference, docstrings, templates, TradingView client
+ type inference, docstrings, formatter, quick fixes, templates,
+ TradingView client
providers/ VS Code adapters: completion, hover, signature help, symbols,
- code actions, diagnostics, library index
+ formatting, code actions, diagnostics, 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 594a571..92ebb32 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.1.0",
+ "version": "3.2.0",
"publisher": "ex-codes",
"author": {
"name": "Yankı Küçük",
@@ -74,7 +74,8 @@
"[pinescript]": {
"editor.tabSize": 4,
"editor.insertSpaces": true,
- "editor.detectIndentation": false
+ "editor.detectIndentation": false,
+ "editor.defaultFormatter": "ex-codes.pine-script-syntax-highlighter"
}
},
"configuration": {
@@ -109,6 +110,11 @@
"type": "boolean",
"default": false,
"description": "Send the document text to the TradingView compiler to show errors and warnings. Off by default because the full script leaves your machine."
+ },
+ "pinescript.format.enabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Format Pine documents on request, indenting blocks with four spaces and spacing operators and arguments."
}
}
},
diff --git a/src/extension/core/diagnostics.ts b/src/extension/core/diagnostics.ts
index 37b971f..20d479c 100644
--- a/src/extension/core/diagnostics.ts
+++ b/src/extension/core/diagnostics.ts
@@ -7,6 +7,8 @@ export interface CompileDiagnostic {
message: string;
severity: 'error' | 'warning';
code: string | null;
+ /** The compiler's own placeholder values, which the quick fixes read. */
+ ctx: Record | undefined;
}
function render(issue: RawIssue): string {
@@ -19,7 +21,7 @@ function one(issue: RawIssue, severity: 'error' | 'warning', lineCount: number):
const startCol = Math.max(0, (issue.start?.column ?? 1) - 1);
const endLine = Math.min(maxLine, Math.max(0, (issue.end?.line ?? issue.start?.line ?? 1) - 1));
const endCol = endLine === line ? Math.max(startCol, (issue.end?.column ?? issue.start?.column ?? 1) - 1) : startCol;
- return { line, startCol, endCol, message: render(issue), severity, code: issue.code ?? null };
+ return { line, startCol, endCol, message: render(issue), severity, code: issue.code ?? null, ctx: issue.ctx };
}
export function toDiagnostics(result: CompileResult, lineCount: number): CompileDiagnostic[] {
@@ -35,6 +37,7 @@ export function toDiagnostics(result: CompileResult, lineCount: number): Compile
message: result.reason ?? 'The TradingView compiler rejected the script.',
severity: 'error',
code: null,
+ ctx: undefined,
});
}
return out;
diff --git a/src/extension/core/formatter.ts b/src/extension/core/formatter.ts
new file mode 100644
index 0000000..0497a47
--- /dev/null
+++ b/src/extension/core/formatter.ts
@@ -0,0 +1,353 @@
+import { tokenize, type Token } from './tokenizer';
+
+export interface FormatOptions {
+ /** Indent with one tab per level instead of four spaces. */
+ useTabs?: boolean;
+}
+
+/** Pine reads a local block as exactly four spaces, or one tab, per level. */
+const INDENT_WIDTH = 4;
+const MAX_BLANK_LINES = 2;
+
+/** Keywords that only ever appear on a line that opens a local block. */
+const BLOCK_KEYWORDS = new Set(['if', 'else', 'for', 'while', 'switch', 'type', 'enum', 'once']);
+
+/** Keywords that cannot be called, so a `(` after them is a group and takes a space. */
+const CONTROL_KEYWORDS = new Set(['if', 'else', 'for', 'while', 'switch', 'and', 'or', 'not', 'to', 'by', 'in']);
+
+/** The only identifiers whose `<` opens a type parameter list rather than a comparison. */
+const GENERIC_OWNERS = new Set(['array', 'matrix', 'map', 'array.new', 'matrix.new', 'map.new']);
+
+/** Operators that cannot end a statement, so the following line continues it. */
+const CONTINUING_OPS = new Set([
+ '+',
+ '-',
+ '*',
+ '/',
+ '%',
+ '?',
+ ':',
+ '=',
+ ':=',
+ '==',
+ '!=',
+ '<',
+ '>',
+ '<=',
+ '>=',
+ '+=',
+ '-=',
+ '*=',
+ '/=',
+ '%=',
+]);
+
+type LineKind = 'blank' | 'comment' | 'code' | 'verbatim';
+
+interface LineInfo {
+ kind: LineKind;
+ /** Indent width of the original line, with tabs counted as one level. */
+ indent: number;
+ /** Block depth of a statement, or of a comment once resolved. */
+ depth: number;
+ continuation: boolean;
+ /** True when a continuation line is held open by an unclosed bracket. */
+ insideBrackets: boolean;
+ /** Index of the statement a continuation line belongs to. */
+ parent: number;
+ /** Depth the author's own indentation suggests, used to place comments. */
+ ownDepth: number;
+ /** Depth of the statement above, used to place comments. */
+ prevDepth: number;
+}
+
+/** Formats a whole document. Lines are never joined or split, only re-indented and re-spaced. */
+export function formatDocument(text: string, options: FormatOptions = {}): string {
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
+ const lines = text.split(/\r?\n/);
+ const tokenized = tokenize(text);
+ const info = classify(lines, tokenized);
+ resolveStructure(lines, tokenized, info);
+ resolveCommentDepth(info);
+ return render(lines, tokenized, info, options).join(eol) + eol;
+}
+
+/** Formats `text` but returns only the lines from `startLine` to `endLine`, both inclusive. */
+export function formatRange(text: string, startLine: number, endLine: number, options: FormatOptions = {}): string {
+ const formatted = formatDocument(text, options).split(/\r?\n/);
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
+ return formatted.slice(startLine, endLine + 1).join(eol);
+}
+
+function classify(lines: string[], tokenized: ReturnType): LineInfo[] {
+ return lines.map((line, i) => {
+ const insideString = i > 0 && tokenized[i - 1]!.continuesString;
+ const significant = tokenized[i]!.tokens.filter((t) => t.kind !== 'ws');
+ let kind: LineKind;
+ if (insideString) kind = 'verbatim';
+ else if (line.trim() === '') kind = 'blank';
+ else if (significant[0]?.kind === 'comment') kind = 'comment';
+ else kind = 'code';
+ return {
+ kind,
+ indent: indentWidth(line),
+ depth: 0,
+ continuation: false,
+ insideBrackets: false,
+ parent: -1,
+ ownDepth: 0,
+ prevDepth: 0,
+ };
+ });
+}
+
+/** Counts the leading whitespace of a line, a tab standing for one indent level. */
+function indentWidth(line: string): number {
+ let width = 0;
+ for (const ch of line) {
+ if (ch === ' ') width++;
+ else if (ch === '\t') width += INDENT_WIDTH;
+ else break;
+ }
+ return width;
+}
+
+/**
+ * Walks the document once, deriving each statement's block depth from the author's own
+ * indentation the way Pine does, and marking the lines that only continue the statement above.
+ */
+function resolveStructure(lines: string[], tokenized: ReturnType, info: LineInfo[]): void {
+ const stack = [0];
+ let lastCode = -1;
+ let lastStatement = -1;
+ let lastStatementDepth = 0;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = info[i]!;
+ if (line.kind === 'blank' || line.kind === 'verbatim') continue;
+ if (line.kind === 'comment') {
+ line.ownDepth = Math.max(0, lastIndexAtOrBelow(stack, line.indent));
+ line.prevDepth = lastStatementDepth;
+ continue;
+ }
+
+ if (isContinuation(i, line, stack, tokenized, lastCode, lastStatement)) {
+ line.continuation = true;
+ line.insideBrackets = tokenized[i]!.depthAtStart > 0;
+ line.parent = lastStatement;
+ lastCode = i;
+ continue;
+ }
+
+ while (stack.length > 1 && line.indent < stack[stack.length - 1]!) stack.pop();
+ if (line.indent > stack[stack.length - 1]!) stack.push(line.indent);
+ line.depth = stack.length - 1;
+ lastCode = i;
+ lastStatement = i;
+ lastStatementDepth = line.depth;
+ }
+}
+
+function isContinuation(
+ index: number,
+ line: LineInfo,
+ stack: number[],
+ tokenized: ReturnType,
+ lastCode: number,
+ lastStatement: number,
+): boolean {
+ if (lastStatement < 0) return false;
+ // An unclosed bracket continues the statement whatever the indentation is.
+ if (tokenized[index]!.depthAtStart > 0) return true;
+ const previous = lastCode >= 0 ? significantTokens(tokenized, lastCode) : [];
+ const last = previous[previous.length - 1];
+ if (last && last.kind === 'comma') return true;
+ if (last && last.kind === 'op' && CONTINUING_OPS.has(last.text)) return true;
+ // Outside brackets Pine reads an indent that is not a multiple of four as a wrapped line.
+ if (line.indent % INDENT_WIDTH === 0) return false;
+ if (stack.includes(line.indent)) return false;
+ if (line.indent <= stack[stack.length - 1]!) return false;
+ return !opensBlock(previous);
+}
+
+/** True when the line above starts a local block, so a deeper line below belongs to that block. */
+function opensBlock(tokens: Token[]): boolean {
+ const last = tokens[tokens.length - 1];
+ if (last && last.kind === 'op' && last.text === '=>') return true;
+ return tokens.some((t) => t.kind === 'ident' && BLOCK_KEYWORDS.has(t.text));
+}
+
+function significantTokens(tokenized: ReturnType, line: number): Token[] {
+ return tokenized[line]!.tokens.filter((t) => t.kind !== 'ws' && t.kind !== 'comment');
+}
+
+function lastIndexAtOrBelow(stack: number[], indent: number): number {
+ let found = 0;
+ for (let i = 0; i < stack.length; i++) if (stack[i]! <= indent) found = i;
+ return found;
+}
+
+/**
+ * A comment sits at the depth of the statement it introduces, but keeps a deeper position when
+ * the author placed it inside the block that just ended.
+ */
+function resolveCommentDepth(info: LineInfo[]): void {
+ let next = 0;
+ for (let i = info.length - 1; i >= 0; i--) {
+ const line = info[i]!;
+ if (line.kind === 'comment') line.depth = Math.max(next, Math.min(line.ownDepth, line.prevDepth));
+ else if (line.kind === 'code' && !line.continuation) next = line.depth;
+ }
+}
+
+function render(
+ lines: string[],
+ tokenized: ReturnType,
+ info: LineInfo[],
+ options: FormatOptions,
+): string[] {
+ const out: string[] = [];
+ let blanks = 0;
+ let previous: Token | undefined;
+ for (let i = 0; i < lines.length; i++) {
+ const line = info[i]!;
+ if (line.kind === 'verbatim') {
+ out.push(lines[i]!);
+ blanks = 0;
+ continue;
+ }
+ if (line.kind === 'blank') {
+ blanks++;
+ if (out.length > 0 && blanks <= MAX_BLANK_LINES) out.push('');
+ continue;
+ }
+ blanks = 0;
+ const code = formatLine(tokenized[i]!.tokens, line.continuation ? previous : undefined);
+ out.push(indentFor(i, info, options) + code);
+ if (line.kind === 'code') {
+ const significant = tokenized[i]!.tokens.filter((t) => t.kind !== 'ws' && t.kind !== 'comment');
+ previous = significant[significant.length - 1] ?? previous;
+ }
+ }
+ while (out.length > 0 && out[out.length - 1] === '') out.pop();
+ return out;
+}
+
+function indentFor(index: number, info: LineInfo[], options: FormatOptions): string {
+ const line = info[index]!;
+ if (!line.continuation) return unit(line.depth, options);
+
+ const parent = info[line.parent];
+ const parentOld = parent ? parent.indent : 0;
+ const parentNew = parent ? parent.depth * INDENT_WIDTH : 0;
+ let width = parentNew + (line.indent - parentOld);
+ if (width < 0) width = 0;
+ // A wrapped line that is not held open by a bracket must not land on a block indent.
+ if (!line.insideBrackets && width % INDENT_WIDTH === 0) width += 1;
+ if (options.useTabs && parent) {
+ const extra = Math.max(0, width - parentNew);
+ return '\t'.repeat(parent.depth) + ' '.repeat(extra);
+ }
+ return ' '.repeat(width);
+}
+
+function unit(depth: number, options: FormatOptions): string {
+ return options.useTabs ? '\t'.repeat(depth) : ' '.repeat(depth * INDENT_WIDTH);
+}
+
+/**
+ * Rebuilds a line from its tokens, applying the spacing rules. Strings and comments are copied as
+ * they are. `previous` is the last token of the statement this line continues, if it continues one.
+ */
+export function formatLine(tokens: Token[], previous?: Token): string {
+ const sig = tokens.filter((t) => t.kind !== 'ws');
+ if (sig.length === 0) return '';
+ const generics = markGenerics(sig);
+ const unary = markUnary(sig, generics, previous);
+ // `import user/library/version` is a path, so its slashes are not division.
+ const isImport = sig[0]!.kind === 'ident' && sig[0]!.text === 'import';
+ let out = '';
+ for (let i = 0; i < sig.length; i++) {
+ const token = sig[i]!;
+ if (token.kind === 'comment' && i > 0) {
+ // Keep the gap the author left before a trailing comment, so aligned comments survive.
+ const gap = Math.max(1, token.start - sig[i - 1]!.end);
+ out += ' '.repeat(gap) + token.text;
+ continue;
+ }
+ if (i > 0 && !(isImport && isSlash(sig, i)) && needsSpace(sig, i, generics, unary)) out += ' ';
+ out += token.text;
+ }
+ return out;
+}
+
+/** Marks the `<` and `>` tokens that delimit a type parameter list. */
+function markGenerics(sig: Token[]): Set {
+ const marks = new Set();
+ const open: number[] = [];
+ for (let i = 0; i < sig.length; i++) {
+ const token = sig[i]!;
+ if (token.kind !== 'op') continue;
+ if (token.text === '<') {
+ const previous = sig[i - 1];
+ if (previous?.kind === 'ident' && GENERIC_OWNERS.has(previous.text)) {
+ open.push(i);
+ marks.add(i);
+ }
+ } else if (token.text === '>' && open.length > 0) {
+ open.pop();
+ marks.add(i);
+ }
+ }
+ for (const unmatched of open) marks.delete(unmatched);
+ return marks;
+}
+
+/** Marks the `+`, `-` and `!` tokens that apply to a single operand instead of joining two. */
+function markUnary(sig: Token[], generics: Set, carried?: Token): Set {
+ const marks = new Set();
+ for (let i = 0; i < sig.length; i++) {
+ const token = sig[i]!;
+ if (token.kind !== 'op' || (token.text !== '-' && token.text !== '+' && token.text !== '!')) continue;
+ const previous = i === 0 ? carried : sig[i - 1];
+ if (
+ !previous ||
+ previous.kind === 'open' ||
+ previous.kind === 'comma' ||
+ (previous.kind === 'op' && !(i > 0 && generics.has(i - 1))) ||
+ (previous.kind === 'ident' && CONTROL_KEYWORDS.has(previous.text))
+ ) {
+ marks.add(i);
+ }
+ }
+ return marks;
+}
+
+/** True when the token at `i`, or the one before it, is the `/` of an import path. */
+function isSlash(sig: Token[], i: number): boolean {
+ const slash = (t: Token | undefined) => t?.kind === 'op' && t.text === '/';
+ return slash(sig[i]) || slash(sig[i - 1]);
+}
+
+function needsSpace(sig: Token[], i: number, generics: Set, unary: Set): boolean {
+ const token = sig[i]!;
+ const previous = sig[i - 1]!;
+ const isDot = (t: Token) => t.kind === 'op' && t.text === '.';
+ if (isDot(token) || isDot(previous)) return false;
+ if (generics.has(i)) return false;
+ if (generics.has(i - 1)) {
+ if (previous.text === '<') return false;
+ return token.kind !== 'open' && token.kind !== 'close' && token.kind !== 'comma';
+ }
+ if (token.kind === 'comma') return false;
+ if (previous.kind === 'comma') return true;
+ if (unary.has(i)) return previous.kind !== 'open';
+ if (unary.has(i - 1)) return false;
+ if (token.kind === 'close') return false;
+ if (previous.kind === 'open') return false;
+ if (token.kind === 'open') {
+ if (previous.kind === 'ident') return CONTROL_KEYWORDS.has(previous.text);
+ return previous.kind === 'op';
+ }
+ return true;
+}
diff --git a/src/extension/core/quick-fix.ts b/src/extension/core/quick-fix.ts
new file mode 100644
index 0000000..0903967
--- /dev/null
+++ b/src/extension/core/quick-fix.ts
@@ -0,0 +1,292 @@
+import type { CompileDiagnostic } from './diagnostics';
+import type { DocumentModel } from './document-model';
+import { ReferenceIndex } from './reference';
+import { tokenize, wordAt, type Token } from './tokenizer';
+
+export interface QuickFixEdit {
+ startLine: number;
+ startCol: number;
+ endLine: number;
+ endCol: number;
+ newText: string;
+}
+
+export interface QuickFix {
+ title: string;
+ edits: QuickFixEdit[];
+ /** Marks the single obvious fix so the editor can apply it without a menu. */
+ preferred: boolean;
+}
+
+/** Compiler codes the quick fixes react to. */
+const CODE = {
+ unknownFunction: 'CE10271',
+ undeclaredIdentifier: 'CE10272',
+ naWithoutType: 'CE10097',
+ unknownArgument: 'CE10120',
+ typeMismatch: 'CE10173',
+ shadowsBuiltIn: 'CW10011',
+} as const;
+
+/** Names that moved somewhere other than a namespace when Pine went from v4 to v5. */
+const RENAMED = new Map([['study', 'indicator']]);
+
+/** 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(
+ issue: CompileDiagnostic,
+ lines: string[],
+ model: DocumentModel,
+ ref: ReferenceIndex,
+): QuickFix[] {
+ switch (issue.code) {
+ case CODE.unknownFunction:
+ case CODE.undeclaredIdentifier:
+ return renameToKnownSymbol(issue, lines, model, ref);
+ case CODE.naWithoutType:
+ return declareWithType(issue, lines);
+ case CODE.unknownArgument:
+ return fixArgumentName(issue, lines, ref);
+ case CODE.typeMismatch:
+ return fixDeclaredType(issue, lines);
+ case CODE.shadowsBuiltIn:
+ return renameShadow(issue, lines, model);
+ default:
+ return addVersionPragma(issue, lines);
+ }
+}
+
+/** Replaces a name the compiler does not know with the closest one it does. */
+function renameToKnownSymbol(
+ issue: CompileDiagnostic,
+ lines: string[],
+ model: DocumentModel,
+ ref: ReferenceIndex,
+): QuickFix[] {
+ const word = wordAt(lines[issue.line] ?? '', issue.startCol);
+ const name = issue.ctx?.fullName ?? issue.ctx?.identifier ?? word?.text;
+ if (!word || !name || word.text !== name) return [];
+
+ 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('.')) {
+ // Most v4 built-ins simply moved into a namespace: `sma` became `ta.sma`.
+ 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 },
+ ],
+ }));
+}
+
+function candidateNames(name: string, model: DocumentModel, ref: ReferenceIndex): string[] {
+ const dot = name.lastIndexOf('.');
+ if (dot > 0) return ref.members(name.slice(0, dot)).map((e) => e.name);
+ return [
+ ...ref.bare().map((e) => e.name),
+ ...ref.childNamespaces(''),
+ ...model.functions.map((f) => f.name),
+ ...model.types.map((t) => t.name),
+ ...model.enums.map((e) => e.name),
+ ...model.variables.map((v) => v.name),
+ ];
+}
+
+/** Adds the type keyword Pine needs when a variable is initialised with `na`. */
+function declareWithType(issue: CompileDiagnostic, lines: string[]): QuickFix[] {
+ const text = lines[issue.line] ?? '';
+ const match = /^(\s*)((?:var|varip)\s+)?([A-Za-z_]\w*)\s*(?::?=)/.exec(text);
+ if (!match) return [];
+ const column = match[1]!.length + (match[2]?.length ?? 0);
+ return DECLARABLE.map((type) => ({
+ title: `Declare \`${match[3]}\` as ${type}`,
+ preferred: false,
+ edits: [{ startLine: issue.line, startCol: column, endLine: issue.line, endCol: column, newText: `${type} ` }],
+ }));
+}
+
+/** Corrects, or removes, a named argument the function does not accept. */
+function fixArgumentName(issue: CompileDiagnostic, lines: string[], ref: ReferenceIndex): QuickFix[] {
+ const text = lines[issue.line] ?? '';
+ const word = wordAt(text, issue.startCol);
+ const name = issue.ctx?.name ?? word?.text;
+ if (!word || !name || word.text !== name) return [];
+
+ const entry = issue.ctx?.signature ? ref.get(issue.ctx.signature, 'function') : undefined;
+ const parameters = [...new Set((entry?.overloads ?? []).flatMap((o) => o.params.map((p) => p.name)))];
+ const fixes: QuickFix[] = nearest(name, parameters)
+ .slice(0, MAX_SUGGESTIONS)
+ .map((parameter) => ({
+ title: `Rename the argument to \`${parameter}\``,
+ preferred: false,
+ edits: [
+ { startLine: issue.line, startCol: word.start, endLine: issue.line, endCol: word.end, newText: parameter },
+ ],
+ }));
+
+ const removal = argumentSpan(text, word.start);
+ if (removal) {
+ fixes.push({
+ title: `Remove the \`${name}\` argument`,
+ preferred: false,
+ edits: [
+ { startLine: issue.line, startCol: removal.start, endLine: issue.line, endCol: removal.end, newText: '' },
+ ],
+ });
+ }
+ if (fixes.length === 1) fixes[0]!.preferred = true;
+ return fixes;
+}
+
+/**
+ * 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 {
+ 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;
+
+ let depth = 0;
+ for (let i = index + 2; i < tokens.length; i++) {
+ const token = tokens[i]!;
+ if (token.kind === 'open') depth++;
+ else if (token.kind === 'close') {
+ if (depth === 0) {
+ // The last argument in the call takes the comma in front of it with it.
+ const previousComma = lastCommaBefore(tokens, index);
+ return previousComma ? { start: previousComma.start, end: tokens[i - 1]!.end } : null;
+ }
+ depth--;
+ } else if (token.kind === 'comma' && depth === 0) {
+ let end = token.end;
+ while (text[end] === ' ') end++;
+ return { start: nameStart, end };
+ }
+ }
+ return null;
+}
+
+function lastCommaBefore(tokens: Token[], index: number): Token | null {
+ for (let i = index - 1; i >= 0; i--) {
+ const token = tokens[i]!;
+ if (token.kind === 'comma') return token;
+ if (token.kind === 'open') return null;
+ }
+ return null;
+}
+
+/** Widens a declared type keyword to the type of the value being assigned. */
+function fixDeclaredType(issue: CompileDiagnostic, lines: string[]): QuickFix[] {
+ const assigned = issue.ctx?.assignedValueType;
+ const declared = issue.ctx?.ownValueType;
+ const variable = issue.ctx?.variableName;
+ if (!assigned || !declared || !variable) return [];
+ const wanted = ReferenceIndex.baseType(assigned);
+ const current = ReferenceIndex.baseType(declared);
+ if (wanted === current) return [];
+
+ const text = lines[issue.line] ?? '';
+ const match = new RegExp(`\\b${current}\\b(?=[^=]*\\b${variable}\\b)`).exec(text);
+ if (!match) return [];
+ return [
+ {
+ title: `Change the declared type to \`${wanted}\``,
+ preferred: true,
+ edits: [
+ {
+ startLine: issue.line,
+ startCol: match.index,
+ endLine: issue.line,
+ endCol: match.index + current.length,
+ newText: wanted,
+ },
+ ],
+ },
+ ];
+}
+
+/** Renames every use of a variable that hides a built-in of the same name. */
+function renameShadow(issue: CompileDiagnostic, lines: string[], model: DocumentModel): QuickFix[] {
+ const name = issue.ctx?.variableName ?? wordAt(lines[issue.line] ?? '', issue.startCol)?.text;
+ if (!name) return [];
+ const taken = new Set(model.variables.map((v) => v.name));
+ let replacement = `${name}Value`;
+ for (let n = 1; taken.has(replacement); n++) replacement = `${name}Value${n}`;
+
+ const edits: QuickFixEdit[] = [];
+ tokenize(lines.join('\n')).forEach((line, number) => {
+ for (const token of line.tokens) {
+ if (token.kind === 'ident' && token.text === name) {
+ edits.push({
+ startLine: number,
+ startCol: token.start,
+ endLine: number,
+ endCol: token.end,
+ newText: replacement,
+ });
+ }
+ }
+ });
+ if (!edits.length) return [];
+ return [{ title: `Rename \`${name}\` to \`${replacement}\``, preferred: true, edits }];
+}
+
+/** Adds the version pragma when the compiler refuses the script for want of one. */
+function addVersionPragma(issue: CompileDiagnostic, lines: string[]): QuickFix[] {
+ if (issue.code !== null || !/supported versions/i.test(issue.message)) return [];
+ if (lines.some((line) => /^\s*\/\/\s*@version\s*=/.test(line))) return [];
+ return [
+ {
+ title: 'Add `//@version=6`',
+ preferred: true,
+ edits: [{ startLine: 0, startCol: 0, endLine: 0, endCol: 0, newText: '//@version=6\n' }],
+ },
+ ];
+}
+
+/** 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/extension.ts b/src/extension/extension.ts
index 3a6627a..c88b06c 100644
--- a/src/extension/extension.ts
+++ b/src/extension/extension.ts
@@ -11,6 +11,7 @@ import { PineFacade } from './core/pine-facade';
import { loadReference } from './core/reference';
import { PineCompletionProvider } from './providers/completion';
import { PineDocumentSymbolProvider } from './providers/document-symbol';
+import { PineFormattingProvider } from './providers/formatting';
import { PineHoverProvider } from './providers/hover';
import { PineSignatureHelpProvider } from './providers/signature-help';
import { forget } from './vscode/document-cache';
@@ -36,7 +37,7 @@ export function activate(context: vscode.ExtensionContext): void {
context.subscriptions.push(
vscode.languages.registerCodeActionsProvider(
SELECTOR,
- new PineCodeActionProvider(),
+ new PineCodeActionProvider(loadReference(), (uri) => diagnostics.issuesFor(uri)),
PineCodeActionProvider.metadata,
),
vscode.workspace.onDidChangeConfiguration((e) => {
@@ -72,6 +73,13 @@ export function registerProviders(context: vscode.ExtensionContext, libraries: L
providerDisposables.push(
vscode.languages.registerSignatureHelpProvider(SELECTOR, new PineSignatureHelpProvider(ref, libraries), '(', ','),
);
+ if (settings.format) {
+ const formatter = new PineFormattingProvider();
+ providerDisposables.push(
+ vscode.languages.registerDocumentFormattingEditProvider(SELECTOR, formatter),
+ vscode.languages.registerDocumentRangeFormattingEditProvider(SELECTOR, formatter),
+ );
+ }
context.subscriptions.push(...providerDisposables);
}
diff --git a/src/extension/providers/code-action.ts b/src/extension/providers/code-action.ts
index 4eac042..f1ab5e3 100644
--- a/src/extension/providers/code-action.ts
+++ b/src/extension/providers/code-action.ts
@@ -1,19 +1,53 @@
import * as vscode from 'vscode';
+
+import type { CompileDiagnostic } from '../core/diagnostics';
import { needsDocstring } from '../core/docstring';
import { declarationAt } from '../core/document-model';
+import { quickFixes, type QuickFix } from '../core/quick-fix';
+import type { ReferenceIndex } from '../core/reference';
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;
+
export class PineCodeActionProvider implements vscode.CodeActionProvider {
static readonly metadata: vscode.CodeActionProviderMetadata = {
- providedCodeActionKinds: [vscode.CodeActionKind.Refactor],
+ providedCodeActionKinds: [vscode.CodeActionKind.QuickFix, vscode.CodeActionKind.Refactor],
};
+ constructor(
+ private readonly ref: ReferenceIndex,
+ private readonly issues: IssueLookup = () => undefined,
+ ) {}
+
provideCodeActions(document: vscode.TextDocument, range: vscode.Range): vscode.CodeAction[] {
const { model } = analyze(document);
+ const actions: vscode.CodeAction[] = [];
+
+ const lines = document.getText().split(/\r?\n/);
+ for (const issue of this.issues(document.uri) ?? []) {
+ if (issue.line < range.start.line || issue.line > range.end.line) continue;
+ for (const fix of quickFixes(issue, lines, model, this.ref)) {
+ actions.push(toAction(fix, document.uri));
+ }
+ }
+
const decl = declarationAt(model, range.start.line);
- if (!decl || !needsDocstring(decl)) return [];
- const action = new vscode.CodeAction(`Generate docstring for ${decl.name}`, vscode.CodeActionKind.Refactor);
- action.command = { command: 'pinescript.generateDocstring', title: 'Generate docstring', arguments: [decl.line] };
- return [action];
+ if (decl && needsDocstring(decl)) {
+ const action = new vscode.CodeAction(`Generate docstring for ${decl.name}`, vscode.CodeActionKind.Refactor);
+ action.command = { command: 'pinescript.generateDocstring', title: 'Generate docstring', arguments: [decl.line] };
+ actions.push(action);
+ }
+ return actions;
+ }
+}
+
+function toAction(fix: QuickFix, uri: vscode.Uri): vscode.CodeAction {
+ const action = new vscode.CodeAction(fix.title, vscode.CodeActionKind.QuickFix);
+ action.isPreferred = fix.preferred;
+ action.edit = new vscode.WorkspaceEdit();
+ for (const edit of fix.edits) {
+ action.edit.replace(uri, new vscode.Range(edit.startLine, edit.startCol, edit.endLine, edit.endCol), edit.newText);
}
+ return action;
}
diff --git a/src/extension/providers/diagnostics-controller.ts b/src/extension/providers/diagnostics-controller.ts
index b2e6ea8..ac6fb82 100644
--- a/src/extension/providers/diagnostics-controller.ts
+++ b/src/extension/providers/diagnostics-controller.ts
@@ -1,5 +1,5 @@
import * as vscode from 'vscode';
-import { compilerVariableTypes, toDiagnostics } from '../core/diagnostics';
+import { compilerVariableTypes, toDiagnostics, type CompileDiagnostic } from '../core/diagnostics';
import type { PineFacade } from '../core/pine-facade';
import type { Settings } from '../vscode/settings';
@@ -9,6 +9,7 @@ export class DiagnosticsController {
private readonly collection = vscode.languages.createDiagnosticCollection('pinescript');
private readonly timers = new Map>();
private readonly types = new Map>();
+ private readonly issues = new Map();
constructor(
private readonly facade: PineFacade,
@@ -24,11 +25,15 @@ export class DiagnosticsController {
vscode.workspace.onDidCloseTextDocument((d) => {
this.collection.delete(d.uri);
this.types.delete(d.uri.toString());
+ this.issues.delete(d.uri.toString());
}),
vscode.workspace.onDidChangeConfiguration((e) => {
if (!e.affectsConfiguration('pinescript.diagnostics.remote')) return;
if (this.settings().diagnosticsRemote) vscode.workspace.textDocuments.forEach((d) => this.schedule(d, 0));
- else this.collection.clear();
+ else {
+ this.collection.clear();
+ this.issues.clear();
+ }
}),
);
vscode.workspace.textDocuments.forEach((d) => this.schedule(d, 0));
@@ -38,6 +43,10 @@ export class DiagnosticsController {
return this.types.get(uri.toString());
}
+ issuesFor(uri: vscode.Uri): readonly CompileDiagnostic[] | undefined {
+ return this.issues.get(uri.toString());
+ }
+
private schedule(document: vscode.TextDocument, delay: number): void {
if (document.languageId !== 'pinescript' || !this.settings().diagnosticsRemote) return;
const key = document.uri.toString();
@@ -53,9 +62,11 @@ export class DiagnosticsController {
const result = await this.facade.translateLight(document.getText());
if (!result || document.isClosed || document.version !== version) return;
this.types.set(document.uri.toString(), compilerVariableTypes(result));
+ const issues = toDiagnostics(result, document.lineCount);
+ this.issues.set(document.uri.toString(), issues);
this.collection.set(
document.uri,
- toDiagnostics(result, document.lineCount).map((d) => {
+ issues.map((d) => {
const diag = new vscode.Diagnostic(
new vscode.Range(d.line, d.startCol, d.line, d.endCol),
d.message,
diff --git a/src/extension/providers/formatting.ts b/src/extension/providers/formatting.ts
new file mode 100644
index 0000000..cedd06b
--- /dev/null
+++ b/src/extension/providers/formatting.ts
@@ -0,0 +1,47 @@
+import * as vscode from 'vscode';
+
+import { formatDocument, formatRange } from '../core/formatter';
+
+/**
+ * Reformats Pine documents. Lines are re-indented and re-spaced but never joined or split, so the
+ * structure the author wrote, and the compiler reads, is preserved.
+ */
+export class PineFormattingProvider
+ implements vscode.DocumentFormattingEditProvider, vscode.DocumentRangeFormattingEditProvider
+{
+ provideDocumentFormattingEdits(document: vscode.TextDocument, options: vscode.FormattingOptions): vscode.TextEdit[] {
+ const text = document.getText();
+ const formatted = formatDocument(text, { useTabs: !options.insertSpaces });
+ if (formatted === text) return [];
+ return [vscode.TextEdit.replace(wholeDocument(document), formatted)];
+ }
+
+ provideDocumentRangeFormattingEdits(
+ document: vscode.TextDocument,
+ range: vscode.Range,
+ options: vscode.FormattingOptions,
+ ): vscode.TextEdit[] {
+ const first = range.start.line;
+ // A selection that stops at the very start of a line does not include that line.
+ const last = range.end.character === 0 && range.end.line > first ? range.end.line - 1 : range.end.line;
+ const formatted = formatRange(document.getText(), first, last, { useTabs: !options.insertSpaces });
+ const target = new vscode.Range(first, 0, last, lineLength(document, last));
+ if (formatted === textOf(document, first, last)) return [];
+ return [vscode.TextEdit.replace(target, formatted)];
+ }
+}
+
+function wholeDocument(document: vscode.TextDocument): vscode.Range {
+ const last = document.lineCount - 1;
+ return new vscode.Range(0, 0, last, lineLength(document, last));
+}
+
+function lineLength(document: vscode.TextDocument, line: number): number {
+ return document.lineAt(line).text.length;
+}
+
+function textOf(document: vscode.TextDocument, first: number, last: number): string {
+ const lines: string[] = [];
+ for (let line = first; line <= last; line++) lines.push(document.lineAt(line).text);
+ return lines.join('\n');
+}
diff --git a/src/extension/vscode/settings.ts b/src/extension/vscode/settings.ts
index 79dfd1c..955aa2d 100644
--- a/src/extension/vscode/settings.ts
+++ b/src/extension/vscode/settings.ts
@@ -7,6 +7,7 @@ export interface Settings {
librariesInclude: string;
librariesRemote: boolean;
diagnosticsRemote: boolean;
+ format: boolean;
}
export function getSettings(): Settings {
@@ -18,5 +19,6 @@ export function getSettings(): Settings {
librariesInclude: c.get('libraries.local.include', '**/*.pine'),
librariesRemote: c.get('libraries.remote', true),
diagnosticsRemote: c.get('diagnostics.remote', false),
+ format: c.get('format.enabled', true),
};
}
diff --git a/tests/core/diagnostics.test.ts b/tests/core/diagnostics.test.ts
index 629f5cd..e471b81 100644
--- a/tests/core/diagnostics.test.ts
+++ b/tests/core/diagnostics.test.ts
@@ -29,8 +29,17 @@ describe('toDiagnostics', () => {
message: 'Undeclared identifier "closee"',
severity: 'error',
code: 'CE10272',
+ ctx: { identifier: 'closee' },
+ },
+ {
+ line: 4,
+ startCol: 0,
+ endCol: 3,
+ message: 'Unused variable',
+ severity: 'warning',
+ code: null,
+ ctx: undefined,
},
- { line: 4, startCol: 0, endCol: 3, message: 'Unused variable', severity: 'warning', code: null },
]);
});
@@ -40,7 +49,9 @@ describe('toDiagnostics', () => {
{ success: false, reason: 'Script too large', errors: [], warnings: [], variables: [], functions: [] },
3,
),
- ).toEqual([{ line: 0, startCol: 0, endCol: 0, message: 'Script too large', severity: 'error', code: null }]);
+ ).toEqual([
+ { line: 0, startCol: 0, endCol: 0, message: 'Script too large', severity: 'error', code: null, ctx: undefined },
+ ]);
});
it('clamps lines beyond the document', () => {
diff --git a/tests/core/fake-vscode.ts b/tests/core/fake-vscode.ts
index f3a5fd7..f2c1f43 100644
--- a/tests/core/fake-vscode.ts
+++ b/tests/core/fake-vscode.ts
@@ -141,10 +141,19 @@ export class TextEdit {
}
export class CodeActionKind {
static Refactor = new CodeActionKind('refactor');
+ static QuickFix = new CodeActionKind('quickfix');
constructor(public value: string) {}
}
+export class WorkspaceEdit {
+ readonly edits: { uri: Uri; range: Range; newText: string }[] = [];
+ replace(uri: Uri, range: Range, newText: string) {
+ this.edits.push({ uri, range, newText });
+ }
+}
export class CodeAction {
command?: unknown;
+ edit?: WorkspaceEdit;
+ isPreferred?: boolean;
constructor(
public title: string,
public kind?: CodeActionKind,
diff --git a/tests/core/formatter.test.ts b/tests/core/formatter.test.ts
new file mode 100644
index 0000000..e366eae
--- /dev/null
+++ b/tests/core/formatter.test.ts
@@ -0,0 +1,242 @@
+import { describe, expect, it } from 'vitest';
+
+import { formatDocument, formatRange } from '../../src/extension/core/formatter';
+
+const format = (text: string): string => formatDocument(text);
+
+describe('formatter: indentation', () => {
+ it('rewrites a two space block to the four spaces Pine requires', () => {
+ expect(format('//@version=6\nif close > open\n x = 1\n')).toBe('//@version=6\nif close > open\n x = 1\n');
+ });
+
+ it('rewrites tabs to four spaces per level', () => {
+ expect(format('if a\n\tif b\n\t\tx = 1\n')).toBe('if a\n if b\n x = 1\n');
+ });
+
+ it('keeps nested blocks and dedents at the right level', () => {
+ const input = 'if a\n if b\n x = 1\n y = 2\nz = 3\n';
+ expect(format(input)).toBe(input);
+ });
+
+ it('normalises an over indented block to a single level', () => {
+ expect(format('if a\n x = 1\n')).toBe('if a\n x = 1\n');
+ });
+
+ it('indents switch cases and their bodies', () => {
+ const input = 'x = switch kind\n "a" => 1\n =>\n 2\n';
+ expect(format(input)).toBe('x = switch kind\n "a" => 1\n =>\n 2\n');
+ });
+
+ it('indents the body of a function declaration', () => {
+ expect(format('f(x) =>\n x * 2\n')).toBe('f(x) =>\n x * 2\n');
+ });
+
+ it('indents type fields and enum members', () => {
+ expect(format('type Point\n float x\n float y\n')).toBe('type Point\n float x\n float y\n');
+ expect(format('enum Mode\n fast = "F"\n')).toBe('enum Mode\n fast = "F"\n');
+ });
+
+ it('indents with tabs when asked to', () => {
+ expect(formatDocument('if a\n x = 1\n', { useTabs: true })).toBe('if a\n\tx = 1\n');
+ });
+});
+
+describe('formatter: wrapped lines', () => {
+ it('leaves a bracketed continuation where the author aligned it', () => {
+ const input = 'plot(close,\n color = color.red)\n';
+ expect(format(input)).toBe(input);
+ });
+
+ it('keeps a bracketed continuation inside a block relative to its statement', () => {
+ const input = 'if a\n f(close,\n open)\n';
+ expect(format(input)).toBe('if a\n f(close,\n open)\n');
+ });
+
+ it('never lets an unbracketed continuation land on a block indent', () => {
+ const out = format('x = close\n + open\n');
+ expect(out).toBe('x = close\n + open\n');
+ for (const line of out.split('\n').slice(1, 2)) expect(line.search(/\S/) % 4).not.toBe(0);
+ });
+
+ it('repairs a wrapped line the compiler would read as a block', () => {
+ // Pine rejects a wrapped line indented by a multiple of four; the formatter shifts it off.
+ expect(format('x = close +\n open\n')).toBe('x = close +\n open\n');
+ });
+
+ it('keeps a unary sign at the start of a wrapped argument list', () => {
+ const input = 'f(close,\n -1)\n';
+ expect(format(input)).toBe(input);
+ });
+
+ it('does not mistake a block body for a continuation', () => {
+ expect(format('for i = 0 to 9\n x := i\n')).toBe('for i = 0 to 9\n x := i\n');
+ });
+});
+
+describe('formatter: spacing', () => {
+ const line = (text: string): string => format(text).trimEnd();
+
+ it('puts single spaces around binary operators', () => {
+ expect(line('x=a+b*c')).toBe('x = a + b * c');
+ expect(line('x = a == b')).toBe('x = a == b');
+ expect(line('x:=a>=b')).toBe('x := a >= b');
+ });
+
+ it('keeps unary signs attached to their operand', () => {
+ expect(line('x = -1')).toBe('x = -1');
+ expect(line('x = a * -1')).toBe('x = a * -1');
+ expect(line('x = f(-1, -2)')).toBe('x = f(-1, -2)');
+ expect(line('x = (-1)')).toBe('x = (-1)');
+ expect(line('x = a - -b')).toBe('x = a - -b');
+ });
+
+ it('spaces commas and calls the usual way', () => {
+ expect(line('plot( close , open )')).toBe('plot(close, open)');
+ expect(line('x = ta.sma( close,14 )')).toBe('x = ta.sma(close, 14)');
+ });
+
+ it('leaves history references and field access tight', () => {
+ expect(line('x = close [1]')).toBe('x = close[1]');
+ expect(line('x = f(a) [1]')).toBe('x = f(a)[1]');
+ });
+
+ it('separates a control keyword from its parenthesis', () => {
+ expect(line('if(close > open)')).toBe('if (close > open)');
+ expect(line('x = not(a)')).toBe('x = not (a)');
+ });
+
+ it('does not separate a conversion function from its parenthesis', () => {
+ expect(line('x = float(close)')).toBe('x = float(close)');
+ expect(line('x = na(close)')).toBe('x = na(close)');
+ });
+
+ it('spaces a ternary and an arrow', () => {
+ expect(line('x=a?b:c')).toBe('x = a ? b : c');
+ expect(line('f(x)=>x')).toBe('f(x) => x');
+ });
+
+ it('keeps type parameters tight and comparisons spaced', () => {
+ expect(line('a = array.new()')).toBe('a = array.new()');
+ expect(line('var array v = na')).toBe('var array v = na');
+ expect(line('m = map.new()')).toBe('m = map.new()');
+ expect(line('b = matrix.new>(2, 2)')).toBe('b = matrix.new>(2, 2)');
+ expect(line('c = a {
+ expect(line('import someone/utils/3 as u')).toBe('import someone/utils/3 as u');
+ expect(line('import someone/utils/3 as u')).toBe('import someone/utils/3 as u');
+ });
+
+ it('still spaces division outside an import', () => {
+ expect(line('x = a/b')).toBe('x = a / b');
+ });
+
+ it('collapses padding used to align assignments', () => {
+ expect(line('int wins = 0')).toBe('int wins = 0');
+ });
+
+ it('leaves string and comment contents alone', () => {
+ expect(line('x = "a + b"')).toBe('x = "a + b"');
+ expect(line('//@version=6')).toBe('//@version=6');
+ expect(line('// spaced comment')).toBe('// spaced comment');
+ });
+
+ it('keeps the gap before a trailing comment', () => {
+ expect(line('x = 1 // note')).toBe('x = 1 // note');
+ expect(line('x = 1// note')).toBe('x = 1 // note');
+ });
+
+ it('preserves a multi line string exactly', () => {
+ const input = 'x = """\n keep this\n"""\n';
+ expect(format(input)).toBe(input);
+ });
+});
+
+describe('formatter: whitespace hygiene', () => {
+ it('strips trailing whitespace and adds a final newline', () => {
+ expect(format('x = 1 \ny = 2')).toBe('x = 1\ny = 2\n');
+ });
+
+ it('collapses long runs of blank lines and trims the edges', () => {
+ expect(format('\n\nx = 1\n\n\n\n\ny = 2\n\n\n')).toBe('x = 1\n\n\ny = 2\n');
+ });
+
+ it('keeps blank lines inside a multi line string', () => {
+ const input = 'x = """\n\n\n\n\n"""\n';
+ expect(format(input)).toBe(input);
+ });
+
+ it('keeps windows line endings', () => {
+ expect(format('x=1\r\ny=2\r\n')).toBe('x = 1\r\ny = 2\r\n');
+ });
+});
+
+describe('formatter: comments', () => {
+ it('moves a comment down to the block it introduces', () => {
+ expect(format('if a\n// note\n x = 1\n')).toBe('if a\n // note\n x = 1\n');
+ });
+
+ it('leaves a section comment at the top level', () => {
+ const input = '// Inputs\nlength = input.int(14)\n';
+ expect(format(input)).toBe(input);
+ });
+
+ it('keeps a trailing comment inside the block it was written in', () => {
+ const input = 'if a\n x = 1\n // end of block\ny = 2\n';
+ expect(format(input)).toBe(input);
+ });
+});
+
+describe('formatter: whole documents', () => {
+ const script = [
+ '//@version=6',
+ 'indicator("Demo",overlay=true)',
+ '',
+ 'length=input.int(14,"Length",minval=1)',
+ 'src = input.source(close,"Source")',
+ '',
+ 'ma(source,len)=>',
+ ' ta.sma(source,len)',
+ '',
+ 'value=ma(src,length)',
+ 'if value>close',
+ ' label.new(bar_index,high,"up")',
+ '',
+ 'plot(value,"MA",color=color.blue)',
+ ].join('\n');
+
+ it('formats a whole script', () => {
+ expect(format(script)).toBe(
+ [
+ '//@version=6',
+ 'indicator("Demo", overlay = true)',
+ '',
+ 'length = input.int(14, "Length", minval = 1)',
+ 'src = input.source(close, "Source")',
+ '',
+ 'ma(source, len) =>',
+ ' ta.sma(source, len)',
+ '',
+ 'value = ma(src, length)',
+ 'if value > close',
+ ' label.new(bar_index, high, "up")',
+ '',
+ 'plot(value, "MA", color = color.blue)',
+ '',
+ ].join('\n'),
+ );
+ });
+
+ it('is idempotent', () => {
+ const once = format(script);
+ expect(format(once)).toBe(once);
+ });
+
+ it('formats a range without touching the rest', () => {
+ expect(formatRange(script, 3, 4)).toBe(
+ 'length = input.int(14, "Length", minval = 1)\nsrc = input.source(close, "Source")',
+ );
+ });
+});
diff --git a/tests/core/providers.test.ts b/tests/core/providers.test.ts
index 5a68bbc..7652283 100644
--- a/tests/core/providers.test.ts
+++ b/tests/core/providers.test.ts
@@ -6,6 +6,7 @@ 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 { PineFormattingProvider } from '../../src/extension/providers/formatting';
import { PineHoverProvider } from '../../src/extension/providers/hover';
import { PineSignatureHelpProvider } from '../../src/extension/providers/signature-help';
import { docstringEdit } from '../../src/extension/commands/generate-docstring';
@@ -141,13 +142,76 @@ describe('document symbols and code actions', () => {
it('offers and applies a docstring for an undocumented function', () => {
const d = doc(fixture('library.pine'));
- const actions = new PineCodeActionProvider().provideCodeActions(d, new vscode.Range(28, 0, 28, 0));
+ const actions = new PineCodeActionProvider(ref).provideCodeActions(d, new vscode.Range(28, 0, 28, 0));
expect(actions.map((a) => a.title)).toEqual(['Generate docstring for internal']);
const { model } = analyze(d);
const edit = docstringEdit(d, declarationAt(model, 28)!);
expect(edit.newText).toBe('//@function internal \n//@param x \n//@returns \n');
expect(edit.range.start.line).toBe(28);
- expect(new PineCodeActionProvider().provideCodeActions(d, new vscode.Range(9, 0, 9, 0))).toEqual([]);
+ expect(new PineCodeActionProvider(ref).provideCodeActions(d, new vscode.Range(9, 0, 9, 0))).toEqual([]);
+ });
+});
+
+describe('formatting provider', () => {
+ const provider = new PineFormattingProvider();
+ const options = { insertSpaces: true, tabSize: 4 } as vscode.FormattingOptions;
+
+ it('replaces the whole document with the formatted text', () => {
+ const d = doc('//@version=6\nif a\n x=1\n');
+ const [edit] = provider.provideDocumentFormattingEdits(d, options);
+ expect(edit!.newText).toBe('//@version=6\nif a\n x = 1\n');
+ expect(edit!.range.start.line).toBe(0);
+ });
+
+ it('makes no edit when the document is already formatted', () => {
+ expect(provider.provideDocumentFormattingEdits(doc('x = 1\n'), options)).toEqual([]);
+ });
+
+ it('indents with tabs when the editor does', () => {
+ const d = doc('if a\n x = 1\n');
+ const [edit] = provider.provideDocumentFormattingEdits(d, { insertSpaces: false, tabSize: 4 });
+ expect(edit!.newText).toBe('if a\n\tx = 1\n');
+ });
+
+ it('formats only the selected lines', () => {
+ const d = doc('x=1\ny=2\nz=3');
+ const [edit] = provider.provideDocumentRangeFormattingEdits(d, new vscode.Range(1, 0, 1, 3), options);
+ expect(edit!.newText).toBe('y = 2');
+ expect(edit!.range.start.line).toBe(1);
+ expect(edit!.range.end.line).toBe(1);
+ });
+});
+
+describe('code action provider: compiler quick fixes', () => {
+ const text = '//@version=6\nindicator("t")\nplot(sma(close, 14))';
+ const issue = {
+ line: 2,
+ startCol: 5,
+ endCol: 8,
+ message: "Could not find function 'sma'",
+ severity: 'error' as const,
+ code: 'CE10271',
+ ctx: { fullName: 'sma', kind: 'function' },
+ };
+
+ it('turns a compiler diagnostic into an applicable quick fix', () => {
+ const d = doc(text);
+ const actions = new PineCodeActionProvider(ref, () => [issue]).provideCodeActions(d, new vscode.Range(2, 0, 2, 0));
+ expect(actions.map((a) => a.title)).toContain('Change to `ta.sma`');
+ const fix = actions.find((a) => a.title === 'Change to `ta.sma`')!;
+ expect(fix.kind?.value).toBe('quickfix');
+ const edits = (fix.edit as unknown as { edits: { newText: string }[] }).edits;
+ expect(edits.map((e) => e.newText)).toEqual(['ta.sma']);
+ });
+
+ it('ignores diagnostics outside the requested range', () => {
+ const d = doc(text);
+ const actions = new PineCodeActionProvider(ref, () => [issue]).provideCodeActions(d, new vscode.Range(0, 0, 0, 0));
+ expect(actions).toEqual([]);
+ });
+
+ it('offers nothing when the compiler has not run', () => {
+ expect(new PineCodeActionProvider(ref).provideCodeActions(doc(text), new vscode.Range(2, 0, 2, 0))).toEqual([]);
});
});
diff --git a/tests/core/quick-fix.test.ts b/tests/core/quick-fix.test.ts
new file mode 100644
index 0000000..3b87a84
--- /dev/null
+++ b/tests/core/quick-fix.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, it } from 'vitest';
+
+import type { CompileDiagnostic } from '../../src/extension/core/diagnostics';
+import { buildModel } from '../../src/extension/core/document-model';
+import { quickFixes, type QuickFix } from '../../src/extension/core/quick-fix';
+import { loadReference } from '../../src/extension/core/reference';
+
+const ref = loadReference();
+
+/** Builds the diagnostic the TradingView compiler returns, with the placeholder values it sends. */
+function issue(partial: Partial & Pick): CompileDiagnostic {
+ return {
+ endCol: partial.startCol,
+ message: '',
+ severity: 'error',
+ code: null,
+ ctx: undefined,
+ ...partial,
+ };
+}
+
+function fixesFor(source: string, diagnostic: CompileDiagnostic): QuickFix[] {
+ const lines = source.split('\n');
+ return quickFixes(diagnostic, lines, buildModel(source), ref);
+}
+
+/** Applies one fix to the source so the test can assert on the resulting document. */
+function apply(source: string, fix: QuickFix): string {
+ const lines = source.split('\n');
+ for (const edit of [...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('quick fixes: names the compiler does not know', () => {
+ it('moves a bare v4 built-in into its namespace', () => {
+ const source = '//@version=6\nindicator("t")\nplot(sma(close, 14))';
+ const fixes = fixesFor(
+ source,
+ issue({ line: 2, startCol: 5, code: 'CE10271', ctx: { fullName: 'sma', kind: 'function' } }),
+ );
+ expect(fixes[0]!.title).toBe('Change to `ta.sma`');
+ expect(apply(source, fixes[0]!)).toContain('plot(ta.sma(close, 14))');
+ });
+
+ it('renames a function that changed name between versions', () => {
+ const source = '//@version=6\nstudy("t")';
+ const fixes = fixesFor(source, issue({ line: 1, startCol: 0, code: 'CE10271', ctx: { fullName: 'study' } }));
+ expect(fixes[0]!.title).toBe('Change to `indicator`');
+ expect(apply(source, fixes[0]!)).toBe('//@version=6\nindicator("t")');
+ });
+
+ it('corrects a typo inside a namespace', () => {
+ const source = '//@version=6\nplot(ta.smaa(close, 14))';
+ const fixes = fixesFor(source, issue({ line: 1, startCol: 5, code: 'CE10271', ctx: { fullName: 'ta.smaa' } }));
+ expect(fixes.map((f) => f.title)).toContain('Change to `ta.sma`');
+ expect(apply(source, fixes[0]!)).toContain('ta.sma(close, 14)');
+ });
+
+ it('corrects a typo in a built-in variable', () => {
+ const source = '//@version=6\nplot(clsoe)';
+ const fixes = fixesFor(source, issue({ line: 1, startCol: 5, code: 'CE10272', ctx: { identifier: 'clsoe' } }));
+ expect(fixes[0]!.title).toBe('Change to `close`');
+ });
+
+ it('suggests a name declared in the document', () => {
+ const source = '//@version=6\nlengthInput = 14\nplot(ta.sma(close, lengthInpt))';
+ const fixes = fixesFor(
+ source,
+ issue({ line: 2, startCol: 19, code: 'CE10272', ctx: { identifier: 'lengthInpt' } }),
+ );
+ expect(fixes[0]!.title).toBe('Change to `lengthInput`');
+ });
+
+ it('offers nothing when no name is close enough', () => {
+ const source = '//@version=6\nplot(zzzzqqqq)';
+ const fixes = fixesFor(source, issue({ line: 1, startCol: 5, code: 'CE10272', ctx: { identifier: 'zzzzqqqq' } }));
+ expect(fixes).toEqual([]);
+ });
+});
+
+describe('quick fixes: declarations', () => {
+ it('adds a type keyword to a variable initialised with na', () => {
+ const source = 'x = na';
+ const fixes = fixesFor(source, issue({ line: 0, startCol: 0, code: 'CE10097' }));
+ expect(fixes.map((f) => f.title)).toEqual([
+ 'Declare `x` as float',
+ 'Declare `x` as int',
+ 'Declare `x` as bool',
+ 'Declare `x` as string',
+ 'Declare `x` as color',
+ ]);
+ expect(apply(source, fixes[0]!)).toBe('float x = na');
+ });
+
+ it('keeps the var keyword in front of the type', () => {
+ const source = 'var x = na';
+ const fixes = fixesFor(source, issue({ line: 0, startCol: 0, code: 'CE10097' }));
+ expect(apply(source, fixes[0]!)).toBe('var float x = na');
+ });
+
+ it('widens a declared type to the type being assigned', () => {
+ const source = 'int x = close';
+ const fixes = fixesFor(
+ source,
+ issue({
+ line: 0,
+ startCol: 0,
+ code: 'CE10173',
+ ctx: { assignedValueType: 'series float', ownValueType: 'const int', variableName: 'x' },
+ }),
+ );
+ expect(fixes[0]!.title).toBe('Change the declared type to `float`');
+ expect(apply(source, fixes[0]!)).toBe('float x = close');
+ });
+});
+
+describe('quick fixes: arguments', () => {
+ it('corrects a misspelt argument name', () => {
+ const source = 'plot(close, titel = "x")';
+ const fixes = fixesFor(
+ source,
+ issue({ line: 0, startCol: 12, code: 'CE10120', ctx: { name: 'titel', signature: 'plot' } }),
+ );
+ expect(fixes[0]!.title).toBe('Rename the argument to `title`');
+ expect(apply(source, fixes[0]!)).toBe('plot(close, title = "x")');
+ });
+
+ it('removes an argument the function dropped', () => {
+ const source = 'plot(close, transp = 50)';
+ const fixes = fixesFor(
+ source,
+ issue({ line: 0, startCol: 12, code: 'CE10120', ctx: { name: 'transp', signature: 'plot' } }),
+ );
+ expect(fixes.map((f) => f.title)).toContain('Remove the `transp` argument');
+ const removal = fixes.find((f) => f.title.startsWith('Remove'))!;
+ expect(apply(source, removal)).toBe('plot(close)');
+ });
+
+ it('removes an argument that is followed by another', () => {
+ const source = 'plot(close, transp = 50, linewidth = 2)';
+ const fixes = fixesFor(
+ source,
+ issue({ line: 0, startCol: 12, code: 'CE10120', ctx: { name: 'transp', signature: 'plot' } }),
+ );
+ const removal = fixes.find((f) => f.title.startsWith('Remove'))!;
+ expect(apply(source, removal)).toBe('plot(close, linewidth = 2)');
+ });
+});
+
+describe('quick fixes: warnings and the pragma', () => {
+ it('renames a variable that hides a built-in everywhere it is used', () => {
+ const source = '//@version=6\nindicator("t")\nopen = 1\nplot(open + open)';
+ const fixes = fixesFor(source, issue({ line: 2, startCol: 0, code: 'CW10011', ctx: { variableName: 'open' } }));
+ expect(fixes[0]!.title).toBe('Rename `open` to `openValue`');
+ expect(apply(source, fixes[0]!)).toBe('//@version=6\nindicator("t")\nopenValue = 1\nplot(openValue + openValue)');
+ });
+
+ it('adds the version pragma when the compiler asks for one', () => {
+ const source = 'indicator("t")\nplot(close)';
+ const fixes = fixesFor(source, issue({ line: 0, startCol: 0, message: 'Supported versions are >= 5' }));
+ expect(fixes[0]!.title).toBe('Add `//@version=6`');
+ expect(apply(source, fixes[0]!)).toBe('//@version=6\nindicator("t")\nplot(close)');
+ });
+
+ it('does not offer the pragma twice', () => {
+ const source = '//@version=6\nindicator("t")';
+ expect(fixesFor(source, issue({ line: 0, startCol: 0, message: 'Supported versions are >= 5' }))).toEqual([]);
+ });
+
+ it('ignores a diagnostic it has no fix for', () => {
+ expect(fixesFor('x = 1', issue({ line: 0, startCol: 0, code: 'CE10156' }))).toEqual([]);
+ });
+});