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.

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, 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([]); + }); +});