Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,068 changes: 378 additions & 2,690 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions packages/language-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@
"prettier-plugin-marko": "^3.3.2",
"relative-import-path": "^1.0.0",
"typescript": "^5.9.2",
"@volar/kit": "^2.4.14",
"@volar/language-core": "^2.4.14",
"@volar/language-server": "^2.4.14",
"@volar/language-service": "^2.4.14",
"@volar/typescript": "^2.4.14",
"@volar/test-utils": "^2.4.14",
"volar-service-css": "^0.0.64",
"volar-service-emmet": "^0.0.64",
"volar-service-html": "^0.0.64",
"volar-service-prettier": "^0.0.64",
"volar-service-typescript": "^0.0.64",
"volar-service-typescript-twoslash-queries": "^0.0.64",
"vscode-css-languageservice": "^6.3.7",
"vscode-languageserver": "^9.0.1",
"vscode-languageserver-textdocument": "^1.0.12",
Expand Down
67 changes: 36 additions & 31 deletions packages/language-server/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ import { Project } from "@marko/language-tools";
import fs from "fs";
import snapshot from "mocha-snap";
import path from "path";
import { CancellationToken, Position } from "vscode-languageserver";
import { TextDocument } from "vscode-languageserver-textdocument";
import { Position } from "vscode-languageserver";
// import { bench, run } from "mitata";
import { URI } from "vscode-uri";
import { TextDocument } from "vscode-languageserver-textdocument";

import MarkoLangaugeService, { documents } from "../service";
import { codeFrame } from "./util/code-frame";
import { getLanguageServer } from "./util/language-service";

Project.setDefaultTypePaths({
internalTypesFile: require.resolve(
Expand All @@ -21,38 +20,33 @@ Project.setDefaultTypePaths({
// const BENCHED = new Set<string>();
const FIXTURE_DIR = path.join(__dirname, "fixtures");

after(async () => {
const handle = await getLanguageServer();
await handle.shutdown();
});

for (const subdir of fs.readdirSync(FIXTURE_DIR)) {
const fixtureSubdir = path.join(FIXTURE_DIR, subdir);

if (!fs.statSync(fixtureSubdir).isDirectory()) continue;
for (const entry of fs.readdirSync(fixtureSubdir)) {
it(entry, async () => {
const serverHandle = await getLanguageServer();

const fixtureDir = path.join(fixtureSubdir, entry);

for (const filename of loadMarkoFiles(fixtureDir)) {
const doc = documents.get(URI.file(filename).toString())!;
const doc = await serverHandle.openTextDocument(filename, "marko");
const code = doc.getText();
const params = {
textDocument: {
uri: doc.uri,
languageId: doc.languageId,
version: doc.version,
text: code,
},
} as const;
documents.doOpen(params);

let results = "";

for (const position of getHovers(doc)) {
const hoverInfo = await MarkoLangaugeService.doHover(
doc,
{
position,
textDocument: doc,
},
CancellationToken.None,
const hoverInfo = await serverHandle.sendHoverRequest(
doc.uri,
position,
);

const loc = { start: position, end: position };

let message = "";
Expand Down Expand Up @@ -87,9 +81,9 @@ for (const subdir of fs.readdirSync(FIXTURE_DIR)) {
language: string;
content: string;
}
| undefined = await MarkoLangaugeService.commands[
"$/showScriptOutput"
](doc.uri);
| undefined = await serverHandle.sendExecuteCommandRequest(
"marko.debug.showScriptOutput",
);
if (scriptOutput) {
await snapshot(scriptOutput.content, {
file: path.relative(
Expand All @@ -108,8 +102,8 @@ for (const subdir of fs.readdirSync(FIXTURE_DIR)) {
language: string;
content: string;
}
| undefined = await MarkoLangaugeService.commands["$/showHtmlOutput"](
doc.uri,
| undefined = await serverHandle.sendExecuteCommandRequest(
"marko.debug.showHtmlOutput",
);
if (htmlOutput) {
await snapshot(htmlOutput.content, {
Expand All @@ -121,12 +115,23 @@ for (const subdir of fs.readdirSync(FIXTURE_DIR)) {
});
}

const errors = await MarkoLangaugeService.doValidate(doc);

if (errors && errors.length) {
const diagnosticReport =
await serverHandle.sendDocumentDiagnosticRequest(doc.uri);
if (
diagnosticReport.kind === "full" &&
diagnosticReport.items &&
diagnosticReport.items.length
) {
results += "## Diagnostics\n";

for (const error of errors) {
diagnosticReport.items.sort((a, b) => {
const lineDiff = a.range.start.line - b.range.start.line;
if (lineDiff === 0) {
return a.range.start.character - b.range.start.character;
}
return lineDiff;
});
for (const error of diagnosticReport.items) {
const loc = {
start: error.range.start,
end: error.range.end,
Expand All @@ -137,7 +142,7 @@ for (const subdir of fs.readdirSync(FIXTURE_DIR)) {
}
}

documents.doClose(params);
await serverHandle.closeTextDocument(doc.uri);

await snapshot(results, {
file: path.relative(fixtureDir, filename.replace(/\.marko$/, ".md")),
Expand Down
177 changes: 31 additions & 146 deletions packages/language-server/src/__tests__/util/language-service.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,27 @@
import type { Extracted } from "@marko/language-tools";
import { getExt } from "@marko/language-tools";
import {
createFSBackedSystem,
createVirtualLanguageServiceHost,
} from "@typescript/vfs";
import { Project } from "@marko/language-tools";
import { LanguageServerHandle, startLanguageServer } from "@volar/test-utils";
import fs from "fs";
import path from "path";
import ts from "typescript";
import * as protocol from "vscode-languageserver-protocol/node";

const rootDir = process.cwd();
const startPosition: ts.LineAndCharacter = {
line: 0,
character: 0,
};

export type Processors = Record<
string,
{
ext: ts.Extension;
kind: ts.ScriptKind;
extract(filename: string, code: string): Extracted;
}
>;
let serverHandle: LanguageServerHandle | undefined;

Project.setDefaultTypePaths({
internalTypesFile: require.resolve(
"@marko/language-tools/marko.internal.d.ts",
),
markoTypesFile: require.resolve("marko/index.d.ts"),
});

export function createLanguageService(
fsMap: Map<string, string>,
processors: Processors,
) {
const getProcessor = (filename: string) =>
processors[getExt(filename)?.slice(1) || ""];
export async function getLanguageServer() {
// Use the fixtures directory as the workspace root for proper type resolution
const fixturesDir = path.resolve(rootDir, "./__tests__/fixtures/");
const compilerOptions: ts.CompilerOptions = {
...ts.getDefaultCompilerOptions(),
rootDir,
rootDir: fixturesDir,
strict: true,
skipLibCheck: true,
noEmitOnError: true,
Expand All @@ -41,132 +31,27 @@ export function createLanguageService(
allowNonTsExtensions: true,
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ESNext,
moduleResolution: ts.ModuleResolutionKind.NodeJs,
};
const rootFiles = [...fsMap.keys()];
const sys = createFSBackedSystem(fsMap, rootDir, ts);

const { languageServiceHost: lsh } = createVirtualLanguageServiceHost(
sys,
rootFiles,
compilerOptions,
ts,
);

const ls = ts.createLanguageService(lsh);
const snapshotCache = new Map<string, [Extracted, ts.IScriptSnapshot]>();

/**
* Trick TypeScript into thinking Marko files are TS/JS files.
*/
lsh.getScriptKind = (filename: string) => {
const processor = getProcessor(filename);
return processor ? processor.kind : ts.ScriptKind.TS;
moduleResolution: ts.ModuleResolutionKind.NodeNext,
};

/**
* A script snapshot is an immutable string of text representing the contents of a file.
* We patch it so that Marko files instead return their extracted ts code.
*/
const getScriptSnapshot = lsh.getScriptSnapshot!.bind(lsh);
lsh.getScriptSnapshot = (filename: string) => {
const processor = getProcessor(filename);
if (processor) {
let cached = snapshotCache.get(filename);
if (!cached) {
const extracted = processor.extract(
filename,
lsh.readFile(filename, "utf-8") || "",
);
snapshotCache.set(
filename,
(cached = [
extracted,
ts.ScriptSnapshot.fromString(extracted.toString()),
]),
);
}

return cached[1];
}

return getScriptSnapshot(filename);
};

/**
* This ensures that any directory reads with specific file extensions also include Marko.
* It is used for example when completing the `from` property of the `import` statement.
*/
const readDirectory = lsh.readDirectory!.bind(lsh);
const additionalExts = Object.keys(processors);
lsh.readDirectory = (path, extensions, exclude, include, depth) => {
return readDirectory(
path,
extensions?.concat(additionalExts),
exclude,
include,
depth,
if (!serverHandle) {
serverHandle = startLanguageServer(path.resolve("./bin.js"));
const tsdkPath = path.dirname(
require.resolve("typescript/lib/typescript.js"),
);
};

/**
* TypeScript doesn't know how to resolve `.marko` files.
* Below we first try to use TypeScripts normal resolution, and then fallback
* to seeing if a `.marko` file exists at the same location.
*/
lsh.resolveModuleNames = (moduleNames, containingFile) => {
const resolvedModules: (
| ts.ResolvedModuleFull
| ts.ResolvedModule
| undefined
)[] = moduleNames.map<ts.ResolvedModule | undefined>(
(moduleName) =>
ts.resolveModuleName(moduleName, containingFile, compilerOptions, sys)
.resolvedModule,
// Initialize the server with the fixtures directory as the root workspace
await serverHandle.initialize(fixturesDir, {
typescript: { tsdk: tsdkPath, compilerOptions },
});

// Ensure that our first test does not suffer from a TypeScript overhead
await serverHandle.sendCompletionRequest(
"file://doesnt-exists",
protocol.Position.create(0, 0),
);
}

for (let i = resolvedModules.length; i--; ) {
if (!resolvedModules[i]) {
const moduleName = moduleNames[i];
const processor = moduleName[0] !== "*" && getProcessor(moduleName);
if (processor && moduleName[0] === ".") {
// For relative paths just see if it exists on disk.
const resolvedFileName = path.resolve(
containingFile,
"..",
moduleName,
);
if (lsh.fileExists(resolvedFileName)) {
resolvedModules[i] = {
resolvedFileName,
extension: processor.ext,
isExternalLibraryImport: false,
};
}
}
}
}

return resolvedModules;
};

/**
* Whenever TypeScript requests line/character info we return with the source
* file line/character if it exists.
*/
const toLineColumnOffset = ls.toLineColumnOffset!;
ls.toLineColumnOffset = (fileName, pos) => {
if (pos === 0) return startPosition;

const extracted = snapshotCache.get(fileName)?.[0];
if (extracted) {
return extracted.sourcePositionAt(pos) || startPosition;
}

return toLineColumnOffset(fileName, pos);
};

return ls;
return serverHandle;
}

export function loadMarkoFiles(dir: string, all = new Set<string>()) {
Expand Down
Loading