diff --git a/CHANGELOG.md b/CHANGELOG.md index 31144e95..a01d20e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ - +6.1.0 / +================ + * new API `LanguageService.findDocumentSymbols2`, returning `DocumentSymbol[]` 6.0.0 / 2022-05-18 ================ diff --git a/package.json b/package.json index d9cb2a82..85bc691e 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "js-beautify": "^1.14.6", "mocha": "^10.0.0", "rimraf": "^3.0.2", + "source-map-support": "^0.5.21", "typescript": "^4.5.5" }, "dependencies": { @@ -40,8 +41,8 @@ "clean": "rimraf lib", "remove-sourcemap-refs": "node ./build/remove-sourcemap-refs.js", "watch": "npm run copy-jsbeautify && tsc -w -p ./src", - "test": "npm run compile && mocha", - "mocha": "mocha", + "test": "npm run compile && npm run mocha", + "mocha": "mocha --require source-map-support/register", "coverage": "npm run compile && npx nyc --reporter=html --reporter=text mocha", "lint": "eslint src/**/*.ts", "update-data": "yarn add @vscode/web-custom-data -D && node ./build/generateData.js", diff --git a/src/cssLanguageService.ts b/src/cssLanguageService.ts index c47cb107..96d1eacf 100644 --- a/src/cssLanguageService.ts +++ b/src/cssLanguageService.ts @@ -23,7 +23,7 @@ import { Diagnostic, Position, CompletionList, Hover, Location, DocumentHighlight, DocumentLink, SymbolInformation, Range, CodeActionContext, Command, CodeAction, ColorInformation, Color, ColorPresentation, WorkspaceEdit, FoldingRange, SelectionRange, TextDocument, - ICSSDataProvider, CSSDataV1, HoverSettings, CompletionSettings, TextEdit, CSSFormatConfiguration + ICSSDataProvider, CSSDataV1, HoverSettings, CompletionSettings, TextEdit, CSSFormatConfiguration, DocumentSymbol } from './cssLanguageTypes'; import { CSSDataManager } from './languageFacts/dataManager'; @@ -53,6 +53,7 @@ export interface LanguageService { */ findDocumentLinks2(document: TextDocument, stylesheet: Stylesheet, documentContext: DocumentContext): Promise; findDocumentSymbols(document: TextDocument, stylesheet: Stylesheet): SymbolInformation[]; + findDocumentSymbols2(document: TextDocument, stylesheet: Stylesheet): DocumentSymbol[]; doCodeActions(document: TextDocument, range: Range, context: CodeActionContext, stylesheet: Stylesheet): Command[]; doCodeActions2(document: TextDocument, range: Range, context: CodeActionContext, stylesheet: Stylesheet): CodeAction[]; findDocumentColors(document: TextDocument, stylesheet: Stylesheet): ColorInformation[]; @@ -92,7 +93,8 @@ function createFacade(parser: Parser, completion: CSSCompletion, hover: CSSHover findDocumentHighlights: navigation.findDocumentHighlights.bind(navigation), findDocumentLinks: navigation.findDocumentLinks.bind(navigation), findDocumentLinks2: navigation.findDocumentLinks2.bind(navigation), - findDocumentSymbols: navigation.findDocumentSymbols.bind(navigation), + findDocumentSymbols: navigation.findSymbolInformations.bind(navigation), + findDocumentSymbols2: navigation.findDocumentSymbols.bind(navigation), doCodeActions: codeActions.doCodeActions.bind(codeActions), doCodeActions2: codeActions.doCodeActions2.bind(codeActions), findDocumentColors: navigation.findDocumentColors.bind(navigation), diff --git a/src/parser/cssNodes.ts b/src/parser/cssNodes.ts index 782cb6e9..d83a1cb6 100644 --- a/src/parser/cssNodes.ts +++ b/src/parser/cssNodes.ts @@ -1187,18 +1187,9 @@ export class Document extends BodyDeclaration { } export class Medialist extends Node { - private mediums?: Nodelist; - constructor(offset: number, length: number) { super(offset, length); } - - public getMediums(): Nodelist { - if (!this.mediums) { - this.mediums = new Nodelist(this); - } - return this.mediums; - } } export class MediaQuery extends Node { @@ -1474,8 +1465,8 @@ export class NumericValue extends Node { export class VariableDeclaration extends AbstractDeclaration { - private variable: Variable | null = null; - private value: Node | null = null; + private variable: Variable | undefined; + private value: Node | undefined; public needsSemicolon: boolean = true; constructor(offset: number, length: number) { @@ -1486,7 +1477,7 @@ export class VariableDeclaration extends AbstractDeclaration { return NodeType.VariableDeclaration; } - public setVariable(node: Variable | null): node is Variable { + public setVariable(node: Variable | undefined | null): node is Variable { if (node) { node.attachTo(this); this.variable = node; @@ -1495,7 +1486,7 @@ export class VariableDeclaration extends AbstractDeclaration { return false; } - public getVariable(): Variable | null { + public getVariable(): Variable | undefined { return this.variable; } @@ -1503,7 +1494,7 @@ export class VariableDeclaration extends AbstractDeclaration { return this.variable ? this.variable.getName() : ''; } - public setValue(node: Node | null): node is Node { + public setValue(node: Node | undefined | null): node is Node { if (node) { node.attachTo(this); this.value = node; @@ -1512,7 +1503,7 @@ export class VariableDeclaration extends AbstractDeclaration { return false; } - public getValue(): Node | null { + public getValue(): Node | undefined { return this.value; } } diff --git a/src/services/cssCompletion.ts b/src/services/cssCompletion.ts index 8878f342..fd00aae9 100644 --- a/src/services/cssCompletion.ts +++ b/src/services/cssCompletion.ts @@ -853,7 +853,7 @@ export class CSSCompletion { public getCompletionsForVariableDeclaration(declaration: nodes.VariableDeclaration, result: CompletionList): CompletionList { if (this.offset && isDefined(declaration.colonPosition) && this.offset > declaration.colonPosition) { - this.getVariableProposals(declaration.getValue(), result); + this.getVariableProposals(declaration.getValue() || null, result); } return result; } diff --git a/src/services/cssNavigation.ts b/src/services/cssNavigation.ts index 896e279c..f6c364a6 100644 --- a/src/services/cssNavigation.ts +++ b/src/services/cssNavigation.ts @@ -6,7 +6,7 @@ import { Color, ColorInformation, ColorPresentation, DocumentHighlight, DocumentHighlightKind, DocumentLink, Location, - Position, Range, SymbolInformation, SymbolKind, TextEdit, WorkspaceEdit, TextDocument, DocumentContext, FileSystemProvider, FileType + Position, Range, SymbolInformation, SymbolKind, TextEdit, WorkspaceEdit, TextDocument, DocumentContext, FileSystemProvider, FileType, DocumentSymbol } from '../cssLanguageTypes'; import * as nls from 'vscode-nls'; import * as nodes from '../parser/cssNodes'; @@ -19,6 +19,8 @@ const localize = nls.loadMessageBundle(); type UnresolvedLinkData = { link: DocumentLink, isRawLink: boolean }; +type DocumentSymbolCollector = (name: string, kind: SymbolKind, symbolNodeOrRange: nodes.Node | Range, nameNodeOrRange: nodes.Node | Range | undefined, bodyNode: nodes.Node | undefined) => void; + const startsWithSchemeRegex = /^\w+:\/\//; const startsWithData = /^data:/; @@ -189,57 +191,93 @@ export class CSSNavigation { return result; } - - public findDocumentSymbols(document: TextDocument, stylesheet: nodes.Stylesheet): SymbolInformation[] { + public findSymbolInformations(document: TextDocument, stylesheet: nodes.Stylesheet): SymbolInformation[] { const result: SymbolInformation[] = []; - stylesheet.accept((node) => { - + const addSymbolInformation = (name: string, kind: SymbolKind, symbolNodeOrRange: nodes.Node | Range) => { + const range = symbolNodeOrRange instanceof nodes.Node ? getRange(symbolNodeOrRange, document) : symbolNodeOrRange; const entry: SymbolInformation = { - name: null!, - kind: SymbolKind.Class, // TODO@Martin: find a good SymbolKind - location: null! + name, + kind, + location: Location.create(document.uri, range) + }; + result.push(entry); + }; + + this.collectDocumentSymbols(document, stylesheet, addSymbolInformation); + + return result; + } + + public findDocumentSymbols(document: TextDocument, stylesheet: nodes.Stylesheet): DocumentSymbol[] { + const result: DocumentSymbol[] = []; + + const parents: [DocumentSymbol, Range][] = []; + + const addDocumentSymbol = (name: string, kind: SymbolKind, symbolNodeOrRange: nodes.Node | Range, nameNodeOrRange: nodes.Node | Range | undefined, bodyNode: nodes.Node | undefined) => { + const range = symbolNodeOrRange instanceof nodes.Node ? getRange(symbolNodeOrRange, document) : symbolNodeOrRange; + const selectionRange = (nameNodeOrRange instanceof nodes.Node ? getRange(nameNodeOrRange, document) : nameNodeOrRange) ?? Range.create(range.start, range.start); + const entry: DocumentSymbol = { + name, + kind, + range, + selectionRange }; - let locationNode: nodes.Node | null = node; - if (node instanceof nodes.Selector) { - entry.name = node.getText(); - locationNode = node.findAParent(nodes.NodeType.Ruleset, nodes.NodeType.ExtendsReference); - if (locationNode) { - entry.location = Location.create(document.uri, getRange(locationNode, document)); - result.push(entry); + let top = parents.pop(); + while (top && !containsRange(top[1], range)) { + top = parents.pop(); + } + if (top) { + const topSymbol = top[0]; + if (!topSymbol.children) { + topSymbol.children = []; + } + topSymbol.children.push(entry); + parents.push(top); // put back top + } else { + result.push(entry); + } + if (bodyNode) { + parents.push([entry, getRange(bodyNode, document)]); + } + }; + + this.collectDocumentSymbols(document, stylesheet, addDocumentSymbol); + + return result; + } + + private collectDocumentSymbols(document: TextDocument, stylesheet: nodes.Stylesheet, collect: DocumentSymbolCollector): void { + stylesheet.accept(node => { + if (node instanceof nodes.RuleSet) { + for (const selector of node.getSelectors().getChildren()) { + if (selector instanceof nodes.Selector) { + const range = Range.create(document.positionAt(selector.offset), document.positionAt(node.end)); + collect(selector.getText(), SymbolKind.Class, range, selector, node.getDeclarations()); + } } - return false; } else if (node instanceof nodes.VariableDeclaration) { - entry.name = (node).getName(); - entry.kind = SymbolKind.Variable; + collect(node.getName(), SymbolKind.Variable, node, node.getVariable(), undefined); } else if (node instanceof nodes.MixinDeclaration) { - entry.name = (node).getName(); - entry.kind = SymbolKind.Method; + collect(node.getName(), SymbolKind.Method, node, node.getIdentifier(), node.getDeclarations()); } else if (node instanceof nodes.FunctionDeclaration) { - entry.name = (node).getName(); - entry.kind = SymbolKind.Function; + collect(node.getName(), SymbolKind.Function, node, node.getIdentifier(), node.getDeclarations()); } else if (node instanceof nodes.Keyframe) { - entry.name = localize('literal.keyframes', "@keyframes {0}", (node).getName()); + const name = localize('literal.keyframes', "@keyframes {0}", node.getName()); + collect(name, SymbolKind.Class, node, node.getIdentifier(), node.getDeclarations()); } else if (node instanceof nodes.FontFace) { - entry.name = localize('literal.fontface', "@font-face"); + const name = localize('literal.fontface', "@font-face"); + collect(name, SymbolKind.Class, node, undefined, node.getDeclarations()); } else if (node instanceof nodes.Media) { const mediaList = node.getChild(0); if (mediaList instanceof nodes.Medialist) { - entry.name = '@media ' + mediaList.getText(); - entry.kind = SymbolKind.Module; + const name = '@media ' + mediaList.getText(); + collect(name, SymbolKind.Module, node, mediaList, node.getDeclarations()); } } - - if (entry.name) { - entry.location = Location.create(document.uri, getRange(locationNode, document)); - result.push(entry); - } - return true; }); - - return result; } public findDocumentColors(document: TextDocument, stylesheet: nodes.Stylesheet): ColorInformation[] { @@ -385,6 +423,28 @@ function getRange(node: nodes.Node, document: TextDocument): Range { return Range.create(document.positionAt(node.offset), document.positionAt(node.end)); } +/** + * Test if `otherRange` is in `range`. If the ranges are equal, will return true. + */ +function containsRange(range: Range, otherRange: Range): boolean { + const otherStartLine = otherRange.start.line, otherEndLine = otherRange.end.line; + const rangeStartLine = range.start.line, rangeEndLine = range.end.line; + + if (otherStartLine < rangeStartLine || otherEndLine < rangeStartLine) { + return false; + } + if (otherStartLine > rangeEndLine || otherEndLine > rangeEndLine) { + return false; + } + if (otherStartLine === rangeStartLine && otherRange.start.character < range.start.character) { + return false; + } + if (otherEndLine === rangeEndLine && otherRange.end.character > range.end.character) { + return false; + } + return true; +} + function getHighlightKind(node: nodes.Node): DocumentHighlightKind { if (node.type === nodes.NodeType.Selector) { diff --git a/src/test/css/navigation.test.ts b/src/test/css/navigation.test.ts index f3ea1d7f..f59e873b 100644 --- a/src/test/css/navigation.test.ts +++ b/src/test/css/navigation.test.ts @@ -12,7 +12,7 @@ import { colorFrom256RGB, colorFromHSL, colorFromHWB } from '../../languageFacts import { TextDocument, DocumentHighlightKind, Range, Position, TextEdit, Color, - ColorInformation, DocumentLink, SymbolKind, SymbolInformation, Location, LanguageService, Stylesheet, getCSSLanguageService, + ColorInformation, DocumentLink, SymbolKind, SymbolInformation, Location, LanguageService, Stylesheet, getCSSLanguageService, DocumentSymbol, } from '../../cssLanguageService'; import { URI } from 'vscode-uri'; @@ -59,7 +59,7 @@ export async function assertLinks(ls: LanguageService, input: string, expected: assert.deepEqual(links, expected); } -export function assertSymbols(ls: LanguageService, input: string, expected: SymbolInformation[], lang: string = 'css') { +export function assertSymbolInfos(ls: LanguageService, input: string, expected: SymbolInformation[], lang: string = 'css') { let document = TextDocument.create(`test://test/test.${lang}`, lang, 0, input); let stylesheet = ls.parseStylesheet(document); @@ -68,6 +68,15 @@ export function assertSymbols(ls: LanguageService, input: string, expected: Symb assert.deepEqual(symbols, expected); } +export function assertDocumentSymbols(ls: LanguageService, input: string, expected: DocumentSymbol[], lang: string = 'css') { + let document = TextDocument.create(`test://test/test.${lang}`, lang, 0, input); + + let stylesheet = ls.parseStylesheet(document); + + let symbols = ls.findDocumentSymbols2(document, stylesheet); + assert.deepEqual(symbols, expected); +} + export function assertColorSymbols(ls: LanguageService, input: string, ...expected: ColorInformation[]) { let document = TextDocument.create('test://test/test.css', 'css', 0, input); @@ -245,13 +254,34 @@ suite('CSS - Navigation', () => { }); suite('Symbols', () => { - test('basic symbols', () => { + test('basic symbol infos', () => { + let ls = getCSSLS(); + assertSymbolInfos(ls, '.foo {}', [{ name: '.foo', kind: SymbolKind.Class, location: Location.create('test://test/test.css', newRange(0, 7)) }]); + assertSymbolInfos(ls, '.foo:not(.selected) {}', [{ name: '.foo:not(.selected)', kind: SymbolKind.Class, location: Location.create('test://test/test.css', newRange(0, 22)) }]); + + // multiple selectors, each range starts with the selector offset + assertSymbolInfos(ls, '.voo.doo, .bar {}', [ + { name: '.voo.doo', kind: SymbolKind.Class, location: Location.create('test://test/test.css', newRange(0, 17)) }, + { name: '.bar', kind: SymbolKind.Class, location: Location.create('test://test/test.css', newRange(10, 17)) }, + ]); + + // Media Query + assertSymbolInfos(ls, '@media screen, print {}', [{ name: '@media screen, print', kind: SymbolKind.Module, location: Location.create('test://test/test.css', newRange(0, 23)) }]); + }); + + test('basic document symbols', () => { let ls = getCSSLS(); - assertSymbols(ls, '.foo {}', [{ name: '.foo', kind: SymbolKind.Class, location: Location.create('test://test/test.css', newRange(0, 7)) }]); - assertSymbols(ls, '.foo:not(.selected) {}', [{ name: '.foo:not(.selected)', kind: SymbolKind.Class, location: Location.create('test://test/test.css', newRange(0, 22)) }]); + assertDocumentSymbols(ls, '.foo {}', [{ name: '.foo', kind: SymbolKind.Class, range: newRange(0, 7), selectionRange: newRange(0, 4) }]); + assertDocumentSymbols(ls, '.foo:not(.selected) {}', [{ name: '.foo:not(.selected)', kind: SymbolKind.Class, range: newRange(0, 22), selectionRange: newRange(0, 19) }]); + + // multiple selectors, each range starts with the selector offset + assertDocumentSymbols(ls, '.voo.doo, .bar {}', [ + { name: '.voo.doo', kind: SymbolKind.Class, range: newRange(0, 17), selectionRange: newRange(0, 8) }, + { name: '.bar', kind: SymbolKind.Class, range: newRange(10, 17), selectionRange: newRange(10, 14) }, + ]); // Media Query - assertSymbols(ls, '@media screen, print {}', [{ name: '@media screen, print', kind: SymbolKind.Module, location: Location.create('test://test/test.css', newRange(0, 23)) }]); + assertDocumentSymbols(ls, '@media screen, print {}', [{ name: '@media screen, print', kind: SymbolKind.Module, range: newRange(0, 23), selectionRange: newRange(7, 20) }]); }); }); diff --git a/src/test/less/lessNavigation.test.ts b/src/test/less/lessNavigation.test.ts index 2eda1099..e39b997c 100644 --- a/src/test/less/lessNavigation.test.ts +++ b/src/test/less/lessNavigation.test.ts @@ -5,7 +5,7 @@ 'use strict'; import * as nodes from '../../parser/cssNodes'; -import { assertScopeBuilding, assertSymbolsInScope, assertScopesAndSymbols, assertHighlights, assertSymbols, newRange, assertColorSymbols } from '../css/navigation.test'; +import { assertScopeBuilding, assertSymbolsInScope, assertScopesAndSymbols, assertHighlights, assertSymbolInfos, newRange, assertColorSymbols, assertDocumentSymbols } from '../css/navigation.test'; import { getLESSLanguageService, SymbolKind, Location } from '../../cssLanguageService'; import { colorFrom256RGB } from '../../languageFacts/facts'; @@ -55,9 +55,25 @@ suite('LESS - Symbols', () => { test('basic symbols', () => { let ls = getLESSLanguageService(); - assertSymbols(ls, '.a(@gutter: @gutter-width) { &:extend(.b); }', [ - { name: '.a', kind: SymbolKind.Method, location: Location.create('test://test/test.css', newRange(0, 44)) }, - { name: '.b', kind: SymbolKind.Class, location: Location.create('test://test/test.css', newRange(29, 41)) } + assertSymbolInfos(ls, '.a(@gutter: @gutter-width) { &:extend(.b); }', [ + { name: '.a', kind: SymbolKind.Method, location: Location.create('test://test/test.css', newRange(0, 44)) } + ]); + assertDocumentSymbols(ls, '.a(@gutter: @gutter-width) { &:extend(.b); }', [ + { name: '.a', kind: SymbolKind.Method, range: newRange(0, 44), selectionRange: newRange(0, 2) } + ]); + + assertSymbolInfos(ls, '.mixin() { .nested() {} }', [ + { name: '.mixin', kind: SymbolKind.Method, location: Location.create('test://test/test.css', newRange(0, 25)) }, + { name: '.nested', kind: SymbolKind.Method, location: Location.create('test://test/test.css', newRange(11, 23)) } + ]); + + assertDocumentSymbols(ls, '.mixin() { .nested() {} }', [ + { + name: '.mixin', kind: SymbolKind.Method, range: newRange(0, 25), selectionRange: newRange(0, 6), + children: [ + { name: '.nested', kind: SymbolKind.Method, range: newRange(11, 23), selectionRange: newRange(11, 18) } + ] + } ]); }); diff --git a/yarn.lock b/yarn.lock index 1b7d4844..084d0581 100644 --- a/yarn.lock +++ b/yarn.lock @@ -267,6 +267,11 @@ browser-stdout@1.3.1: resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -1181,6 +1186,19 @@ slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +source-map-support@^0.5.21: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + string-width@^4.1.0: version "4.2.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5"