From 6eda5f6168bb326883f5fd44508788c089d78b22 Mon Sep 17 00:00:00 2001 From: Tony <68118705+Legend-Master@users.noreply.github.com> Date: Mon, 12 Jun 2023 00:15:03 +0800 Subject: [PATCH 1/3] Add selection ranges support (#891) * Add basic selection ranges support and test * Minor refactor * Populate selection ranges test * More selection ranges tests * Fix missing typing - https://github.com/redhat-developer/yaml-language-server/pull/891#discussion_r1225634641 --- .../handlers/languageHandlers.ts | 15 +- .../services/yamlSelectionRanges.ts | 107 ++++++++++ src/languageservice/yamlLanguageService.ts | 4 + src/yamlServerInit.ts | 1 + test/yamlSelectionRanges.test.ts | 190 ++++++++++++++++++ 5 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 src/languageservice/services/yamlSelectionRanges.ts create mode 100644 test/yamlSelectionRanges.test.ts diff --git a/src/languageserver/handlers/languageHandlers.ts b/src/languageserver/handlers/languageHandlers.ts index 148ad51f3..7820f06e7 100644 --- a/src/languageserver/handlers/languageHandlers.ts +++ b/src/languageserver/handlers/languageHandlers.ts @@ -2,7 +2,7 @@ * Copyright (c) Red Hat, Inc. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { FoldingRange } from 'vscode-json-languageservice'; + import { Connection } from 'vscode-languageserver'; import { CodeActionParams, @@ -12,6 +12,7 @@ import { DocumentOnTypeFormattingParams, DocumentSymbolParams, FoldingRangeParams, + SelectionRangeParams, TextDocumentPositionParams, CodeLensParams, DefinitionParams, @@ -24,6 +25,8 @@ import { DocumentLink, DocumentSymbol, Hover, + FoldingRange, + SelectionRange, SymbolInformation, TextEdit, } from 'vscode-languageserver-types'; @@ -61,6 +64,7 @@ export class LanguageHandlers { this.connection.onCompletion((textDocumentPosition) => this.completionHandler(textDocumentPosition)); this.connection.onDidChangeWatchedFiles((change) => this.watchedFilesHandler(change)); this.connection.onFoldingRanges((params) => this.foldingRangeHandler(params)); + this.connection.onSelectionRanges((params) => this.selectionRangeHandler(params)); this.connection.onCodeAction((params) => this.codeActionHandler(params)); this.connection.onDocumentOnTypeFormatting((params) => this.formatOnTypeHandler(params)); this.connection.onCodeLens((params) => this.codeLensHandler(params)); @@ -207,6 +211,15 @@ export class LanguageHandlers { return this.languageService.getFoldingRanges(textDocument, context); } + selectionRangeHandler(params: SelectionRangeParams): SelectionRange[] | undefined { + const textDocument = this.yamlSettings.documents.get(params.textDocument.uri); + if (!textDocument) { + return; + } + + return this.languageService.getSelectionRanges(textDocument, params.positions); + } + codeActionHandler(params: CodeActionParams): CodeAction[] | undefined { const textDocument = this.yamlSettings.documents.get(params.textDocument.uri); if (!textDocument) { diff --git a/src/languageservice/services/yamlSelectionRanges.ts b/src/languageservice/services/yamlSelectionRanges.ts new file mode 100644 index 000000000..b361ba8d7 --- /dev/null +++ b/src/languageservice/services/yamlSelectionRanges.ts @@ -0,0 +1,107 @@ +import { Position, Range, SelectionRange } from 'vscode-languageserver-types'; +import { yamlDocumentsCache } from '../parser/yaml-documents'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { ASTNode } from 'vscode-json-languageservice'; + +export function getSelectionRanges(document: TextDocument, positions: Position[]): SelectionRange[] | undefined { + if (!document) { + return; + } + const doc = yamlDocumentsCache.getYamlDocument(document); + return positions.map((position) => { + const ranges = getRanges(position); + let current: SelectionRange; + for (const range of ranges) { + current = SelectionRange.create(range, current); + } + if (!current) { + current = SelectionRange.create({ + start: position, + end: position, + }); + } + return current; + }); + + function getRanges(position: Position): Range[] { + const offset = document.offsetAt(position); + const result: Range[] = []; + for (const ymlDoc of doc.documents) { + let currentNode: ASTNode; + let firstNodeOffset: number; + let isFirstNode = true; + ymlDoc.visit((node) => { + const endOffset = node.offset + node.length; + // Skip if end offset doesn't even reach cursor position + if (endOffset < offset) { + return true; + } + let startOffset = node.offset; + // Recheck start offset with the trimmed one in case of this + // key: + // - value + // ↑ + if (startOffset > offset) { + const nodePosition = document.positionAt(startOffset); + if (nodePosition.line !== position.line) { + return true; + } + const lineBeginning = { line: nodePosition.line, character: 0 }; + const text = document.getText({ + start: lineBeginning, + end: nodePosition, + }); + if (text.trim().length !== 0) { + return true; + } + startOffset = document.offsetAt(lineBeginning); + if (startOffset > offset) { + return true; + } + } + // Allow equal for children to override + if (!currentNode || startOffset >= currentNode.offset) { + currentNode = node; + firstNodeOffset = startOffset; + } + return true; + }); + while (currentNode) { + const startOffset = isFirstNode ? firstNodeOffset : currentNode.offset; + const endOffset = currentNode.offset + currentNode.length; + const range = { + start: document.positionAt(startOffset), + end: document.positionAt(endOffset), + }; + const text = document.getText(range); + const trimmedText = text.trimEnd(); + const trimmedLength = text.length - trimmedText.length; + if (trimmedLength > 0) { + range.end = document.positionAt(endOffset - trimmedLength); + } + // Add a jump between '' "" {} [] + const isSurroundedBy = (startCharacter: string, endCharacter?: string): boolean => { + return trimmedText.startsWith(startCharacter) && trimmedText.endsWith(endCharacter || startCharacter); + }; + if ( + (currentNode.type === 'string' && (isSurroundedBy("'") || isSurroundedBy('"'))) || + (currentNode.type === 'object' && isSurroundedBy('{', '}')) || + (currentNode.type === 'array' && isSurroundedBy('[', ']')) + ) { + result.push({ + start: document.positionAt(startOffset + 1), + end: document.positionAt(endOffset - 1), + }); + } + result.push(range); + currentNode = currentNode.parent; + isFirstNode = false; + } + // A position can't be in multiple documents + if (result.length > 0) { + break; + } + } + return result.reverse(); + } +} diff --git a/src/languageservice/yamlLanguageService.ts b/src/languageservice/yamlLanguageService.ts index 196845fcc..6fd1153da 100644 --- a/src/languageservice/yamlLanguageService.ts +++ b/src/languageservice/yamlLanguageService.ts @@ -24,6 +24,7 @@ import { DocumentLink, CodeLens, DefinitionLink, + SelectionRange, } from 'vscode-languageserver-types'; import { JSONSchema } from './jsonSchema'; import { YAMLDocumentSymbols } from './services/documentSymbols'; @@ -52,6 +53,7 @@ import { yamlDocumentsCache } from './parser/yaml-documents'; import { SettingsState } from '../yamlSettings'; import { JSONSchemaSelection } from '../languageserver/handlers/schemaSelectionHandlers'; import { YamlDefinition } from './services/yamlDefinition'; +import { getSelectionRanges } from './services/yamlSelectionRanges'; export enum SchemaPriority { SchemaStore = 1, @@ -173,6 +175,7 @@ export interface LanguageService { deleteSchemaContent(schemaDeletions: SchemaDeletions): void; deleteSchemasWhole(schemaDeletions: SchemaDeletionsAll): void; getFoldingRanges(document: TextDocument, context: FoldingRangesContext): FoldingRange[] | null; + getSelectionRanges(document: TextDocument, positions: Position[]): SelectionRange[] | undefined; getCodeAction(document: TextDocument, params: CodeActionParams): CodeAction[] | undefined; getCodeLens(document: TextDocument): Thenable | CodeLens[] | undefined; resolveCodeLens(param: CodeLens): Thenable | CodeLens; @@ -254,6 +257,7 @@ export function getLanguageService(params: { return schemaService.deleteSchemas(schemaDeletions); }, getFoldingRanges, + getSelectionRanges, getCodeAction: (document, params) => { return yamlCodeActions.getCodeAction(document, params); }, diff --git a/src/yamlServerInit.ts b/src/yamlServerInit.ts index c2a8a0e73..9640e48df 100644 --- a/src/yamlServerInit.ts +++ b/src/yamlServerInit.ts @@ -110,6 +110,7 @@ export class YAMLServerInit { definitionProvider: true, documentLinkProvider: {}, foldingRangeProvider: true, + selectionRangeProvider: true, codeActionProvider: true, codeLensProvider: { resolveProvider: false, diff --git a/test/yamlSelectionRanges.test.ts b/test/yamlSelectionRanges.test.ts new file mode 100644 index 000000000..3ac78ab41 --- /dev/null +++ b/test/yamlSelectionRanges.test.ts @@ -0,0 +1,190 @@ +import { expect } from 'chai'; +import { Position, Range, SelectionRange } from 'vscode-languageserver-types'; +import { setupTextDocument } from './utils/testHelper'; +import { getSelectionRanges } from '../src/languageservice/services/yamlSelectionRanges'; + +function isRangesEqual(range1: Range, range2: Range): boolean { + return ( + range1.start.line === range2.start.line && + range1.start.character === range2.start.character && + range1.end.line === range2.end.line && + range1.end.character === range2.end.character + ); +} + +function expectSelections(selectionRange: SelectionRange, ranges: Range[]): void { + for (const range of ranges) { + expect(selectionRange.range).eql(range); + // Deduplicate ranges + while (selectionRange.parent && isRangesEqual(selectionRange.range, selectionRange.parent.range)) { + selectionRange = selectionRange.parent; + } + selectionRange = selectionRange.parent; + } +} + +describe('YAML Selection Ranges Tests', () => { + it('selection ranges for mapping', () => { + const yaml = 'key: value'; + const positions: Position[] = [ + { + line: 0, + character: 1, + }, + ]; + const document = setupTextDocument(yaml); + const ranges = getSelectionRanges(document, positions); + expect(ranges.length).equal(positions.length); + expectSelections(ranges[0], [ + { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } }, + { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } }, + ]); + }); + + it('selection ranges for sequence', () => { + const yaml = ` +key: + - 1 + - word + `; + let positions: Position[] = [ + { + line: 3, + character: 8, + }, + ]; + const document = setupTextDocument(yaml); + let ranges = getSelectionRanges(document, positions); + expect(ranges.length).equal(positions.length); + expectSelections(ranges[0], [ + { start: { line: 3, character: 4 }, end: { line: 3, character: 8 } }, + { start: { line: 2, character: 2 }, end: { line: 3, character: 8 } }, + { start: { line: 1, character: 0 }, end: { line: 3, character: 8 } }, + ]); + + positions = [ + { + line: 2, + character: 0, + }, + ]; + ranges = getSelectionRanges(document, positions); + expect(ranges.length).equal(positions.length); + expectSelections(ranges[0], [ + { start: { line: 2, character: 0 }, end: { line: 3, character: 8 } }, + { start: { line: 1, character: 0 }, end: { line: 3, character: 8 } }, + ]); + }); + + it('selection ranges jump for "" \'\'', () => { + const yaml = ` +- "word" +- 'word' + `; + let positions: Position[] = [ + { + line: 1, + character: 4, + }, + ]; + const document = setupTextDocument(yaml); + let ranges = getSelectionRanges(document, positions); + expect(ranges.length).equal(positions.length); + expectSelections(ranges[0], [ + { start: { line: 1, character: 3 }, end: { line: 1, character: 7 } }, + { start: { line: 1, character: 2 }, end: { line: 1, character: 8 } }, + ]); + + positions = [ + { + line: 2, + character: 4, + }, + ]; + ranges = getSelectionRanges(document, positions); + expect(ranges.length).equal(positions.length); + expectSelections(ranges[0], [ + { start: { line: 2, character: 3 }, end: { line: 2, character: 7 } }, + { start: { line: 2, character: 2 }, end: { line: 2, character: 8 } }, + ]); + }); + + it('selection ranges jump for [] {}', () => { + const yaml = '{ key: [1, true] }'; + const positions: Position[] = [ + { + line: 0, + character: 12, + }, + ]; + const document = setupTextDocument(yaml); + const ranges = getSelectionRanges(document, positions); + expect(ranges.length).equal(positions.length); + expectSelections(ranges[0], [ + { start: { line: 0, character: 11 }, end: { line: 0, character: 15 } }, + { start: { line: 0, character: 8 }, end: { line: 0, character: 15 } }, + { start: { line: 0, character: 7 }, end: { line: 0, character: 16 } }, + { start: { line: 0, character: 2 }, end: { line: 0, character: 16 } }, + { start: { line: 0, character: 1 }, end: { line: 0, character: 17 } }, + { start: { line: 0, character: 0 }, end: { line: 0, character: 18 } }, + ]); + }); + + it('selection ranges for multiple positions', () => { + const yaml = ` +mapping: + key: value +sequence: + - 1 + - null + `; + const positions: Position[] = [ + { + line: 2, + character: 10, + }, + { + line: 5, + character: 8, + }, + ]; + const document = setupTextDocument(yaml); + const ranges = getSelectionRanges(document, positions); + expect(ranges.length).equal(positions.length); + expectSelections(ranges[0], [ + { start: { line: 2, character: 7 }, end: { line: 2, character: 12 } }, + { start: { line: 2, character: 2 }, end: { line: 2, character: 12 } }, + { start: { line: 1, character: 0 }, end: { line: 2, character: 12 } }, + ]); + expectSelections(ranges[1], [ + { start: { line: 5, character: 4 }, end: { line: 5, character: 8 } }, + { start: { line: 4, character: 2 }, end: { line: 5, character: 8 } }, + { start: { line: 3, character: 0 }, end: { line: 5, character: 8 } }, + ]); + }); + + it('selection ranges for multiple documents', () => { + const yaml = ` +document1: + key: value +--- +document2: + - 1 + - null + `; + const positions: Position[] = [ + { + line: 5, + character: 5, + }, + ]; + const document = setupTextDocument(yaml); + const ranges = getSelectionRanges(document, positions); + expect(ranges.length).equal(positions.length); + expectSelections(ranges[0], [ + { start: { line: 5, character: 4 }, end: { line: 5, character: 5 } }, + { start: { line: 5, character: 2 }, end: { line: 6, character: 8 } }, + { start: { line: 4, character: 0 }, end: { line: 6, character: 8 } }, + ]); + }); +}); From c5f138ffef3d1b56d28e40cafcc398a78ed126a2 Mon Sep 17 00:00:00 2001 From: Gorkem Ercan Date: Sun, 11 Jun 2023 22:19:59 -0400 Subject: [PATCH 2/3] Change regex (#885) Changes regex to be more determenistic. --- src/languageservice/services/yamlHover.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/languageservice/services/yamlHover.ts b/src/languageservice/services/yamlHover.ts index 0a5fb2a0f..8807a6d17 100644 --- a/src/languageservice/services/yamlHover.ts +++ b/src/languageservice/services/yamlHover.ts @@ -89,7 +89,7 @@ export class YAMLHover { const createHover = (contents: string): Hover => { if (this.indentation !== undefined) { - const indentationMatchRegex = new RegExp(this.indentation, 'g'); + const indentationMatchRegex = new RegExp(` {${this.indentation.length}}`, 'g'); contents = contents.replace(indentationMatchRegex, ' '); } From b0f60d4e3df1b453fb4976ab71e16e4bddf92c50 Mon Sep 17 00:00:00 2001 From: Gorkem Ercan Date: Sat, 10 Jun 2023 12:56:57 -0400 Subject: [PATCH 3/3] Pin versions and add permissions Pins the versions of all actions. Adds the minimum required permissions. --- .github/workflows/CI.yaml | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 936179eac..d23790a09 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -10,10 +10,18 @@ on: pull_request: branches: [main] +permissions: + contents: read + + # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # This workflow contains a single job called "build" build: + permissions: + checks: write ## for coveralls + contents: read ## for docker-push + security-events: write ## for upload-sarif # The type of runner that the job will run on runs-on: ${{ matrix.os }} strategy: @@ -23,11 +31,11 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 # Set up Node - name: Use Node 16 - uses: actions/setup-node@v1 + uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2.5.2 with: node-version: 16 registry-url: "https://registry.npmjs.org" @@ -56,13 +64,13 @@ jobs: # Run tests - name: Run Test - uses: GabrielBB/xvfb-action@fe2609f8182a9ed5aee7d53ff3ed04098a904df2 #v1.0 + uses: coactions/setup-xvfb@b6b4fcfb9f5a895edadc3bc76318fae0ac17c8b3 # v1.0.1 with: run: yarn coveralls # Run Coveralls - name: Coveralls - uses: coverallsapp/github-action@3284643be2c47fb6432518ecec17f1255e8a06a6 #master + uses: coverallsapp/github-action@c7885c00cb7ec0b8f9f5ff3f53cddb980f7a4412 # v2.2.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -75,17 +83,17 @@ jobs: # Setup QEMU as requirement for docker - name: Set up QEMU if: ${{ success() && runner.os == 'Linux' && github.event_name == 'push' && github.ref == 'refs/heads/main'}} - uses: docker/setup-qemu-action@v1 - + uses: docker/setup-qemu-action@2b82ce82d56a2a04d2637cd93a637ae1b359c0a7 # v2.2.0 + # Setup DockerBuildx as requirement for docker - name: Set up Docker Buildx if: ${{ success() && runner.os == 'Linux' && github.event_name == 'push' && github.ref == 'refs/heads/main'}} - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@6a58db7e0d21ca03e6c44877909e80e45217eed2 # v2.6.0 # Login to Quay - name: Login to Quay if: ${{ success() && runner.os == 'Linux' && github.event_name == 'push' && github.ref == 'refs/heads/main'}} - uses: docker/login-action@v1 + uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2.2.0 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} @@ -94,7 +102,7 @@ jobs: # Build and push the latest version of yaml language server image - name: Build and push if: ${{ success() && runner.os == 'Linux' && github.event_name == 'push' && github.ref == 'refs/heads/main'}} - uses: docker/build-push-action@v2 + uses: docker/build-push-action@44ea916f6c540f9302d50c2b1e5a8dc071f15cdf #v4.1.0 with: context: . file: ./Dockerfile