commit 57682ed72bb14dea16c460fc1cf1287de383c6e4 Author: Andre Henriques Date: Mon Dec 25 22:44:16 2023 +0000 Inital work on the lsp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6db8392 --- /dev/null +++ b/.gitignore @@ -0,0 +1,178 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +res +test diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b993cb --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# latex-lsp + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.0.20. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..6d49e0e Binary files /dev/null and b/bun.lockb differ diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..816b1ef --- /dev/null +++ b/index.ts @@ -0,0 +1,16 @@ +import fs from 'fs'; +import process from 'process'; +import { getDiagnostics, parseLsp } from './parser'; + +if (process.argv.length !== 3) { + console.log("Please provide only one pass"); + process.exit(1); +} + +const file = fs.readFileSync(process.argv[2]).toString(); + +const res = parseLsp(file); + +fs.writeFileSync('./res', res.text); + +console.log(await getDiagnostics(file)); diff --git a/lsp.ts b/lsp.ts new file mode 100644 index 0000000..980f666 --- /dev/null +++ b/lsp.ts @@ -0,0 +1,218 @@ +import fs from 'fs'; +import * as process from 'process'; +import { getDiagnostics } from './parser'; + +export function log(str: string) { + fs.appendFileSync('/home/sylv/latex-lsp/test', str + "\n"); +} + +export function error(str: string) { + log("ERROR:" + str); + process.exit(1); +} + +let init = false; + +type RPCResult = { + jsonrpc: string, + id: string, +} & ({ + result: any, +} | { + error: any, +}) + +type RPCRequest = { + id: string; + jsonrpc: string, + method: string, + params: string, +} + +type InitRequest = RPCRequest & { + method: 'initialize', +} + +// ASSUMES that is has the right format +function sendRPC(obj: any) { + const msg = JSON.stringify(obj); + const buff = Buffer.from(msg, 'utf-8'); + + const fullmessage = `Content-Length: ${buff.byteLength}\r\n\r\n${msg}`; + + log("Sending message: " + fullmessage.replace(/\n/g, '\\n').replace(/\r/g, '\\r')) + + process.stdout.cork() + process.stdout.write(fullmessage); + process.stdout.uncork(); +} + +function sendRPCMessage(req: RPCRequest, result: any = undefined, error: any = undefined) { + if (!result && !error) { + error("Invalid rpc message to send") + return; + } + + sendRPC({ + id: req.id, + jsonrpc: req.jsonrpc, + result, + error + } as RPCResult) +} + +function notifyRPC(method: string, params: any) { + sendRPC({ + jsonrpc: '2.0', + method, + params, + }) +} + +type TextDocumentDidOpen = RPCRequest & { + method: 'textDocument/didOpen', + params: { + textDocument: { + uri: string; + languageId: string; + version: number; + text: string; + } + } +} + +type Position = { + line: number, + character: number +} + +type Range = { + start: Position, + end: Position, +} + +export type Dialog = { + range: Range, + message: string, + severity: Severity, +} + +export enum Severity { + Error = 1, + Warning = 2, + Information = 3, + Hint = 4, +} + +async function handleJSON(req: RPCRequest) { + log(`New Message entered: method: ${req.method}`); + + if (init) { + if (req.method === 'initialized') { + // On init confirm do nothing + return; + } else if (req.method === 'textDocument/didOpen') { + const reqO: TextDocumentDidOpen = req as any; + + if (reqO.params.textDocument.languageId !== 'latex') { + error(`This server only supports latex! Got: ${reqO.params.textDocument.languageId}`) + return; + } + + const params = { + uri: reqO.params.textDocument.uri, + diagnostics: (await getDiagnostics(reqO.params.textDocument.text)), + } + + notifyRPC('textDocument/publishDiagnostics', params); + + + //error(`TODO ${req.method}`); + } else { + error(`Handle: ${req.method} after init`); + } + } else { + if (req.method === 'initialize') { + log("Recived init"); + + init = true; + + log(JSON.stringify(req)); + + sendRPCMessage(req, { + capabilities: { + diagnosticProvider: { + // TODO change in the future when the server also checks the ib file as well + interFileDependencies: false, + workspaceDiagnostics: false, + } + }, + serverInfo: { + name: "Super Cool latex server", + version: "1.0.0", + } + }); + + return; + } else { + error(`Expected init method found '${req.method}'`) + } + } +} + + +export function handleData(data: Buffer) { + let strData = data.toString(); + let jsonData = strData.split('\n'); + + let req: RPCRequest; + + if (jsonData.length === 3) { + try { + req = JSON.parse(jsonData[2]); + } catch (e) { + log("Failed to parse json 1/2! Msg:\n"); + log(strData.replace(/\n/g, '\\n').replace(/\r/g, '\\r')); + log("\n\nFailed to parse json 2/2! JSON Line:\n"); + log(jsonData[2]); + return; + } + handleJSON(req); + return; + } + + for (let i = 2; i < jsonData.length; i += 2) { + + let jsonLine = jsonData[i]; + if (i != jsonData.length - 1) { + let index = jsonLine.indexOf('Content-Length'); + if (index == -1) { + error("Handling multiline expected 'Content-Length'"); + return; + } + + try { + req = JSON.parse(jsonLine.substring(0, index)); + } catch (e) { + log("Failed to parse json 1/2! Msg:\n"); + log(strData.replace(/\n/g, '\\n').replace(/\r/g, '\\r')); + log("\n\nFailed to parse json 2/2! JSON Line:\n"); + log(jsonLine.substring(0, index)); + return; + } + } else { + try { + req = JSON.parse(jsonLine); + } catch (e) { + log("Failed to parse json 1/2! Msg:\n"); + log(strData.replace(/\n/g, '\\n').replace(/\r/g, '\\r')); + log("\n\nFailed to parse json 2/2! JSON Line:\n"); + log(jsonLine); + return; + } + + } + + handleJSON(req); + } +} diff --git a/lsp_run.ts b/lsp_run.ts new file mode 100644 index 0000000..b686078 --- /dev/null +++ b/lsp_run.ts @@ -0,0 +1,13 @@ +import { error, handleData, log } from "./lsp"; + +console.log = log; + +process.stdin.resume(); + +process.stdin.on('data', function(data) { + try { + handleData(data); + } catch (e) { + error("TRY GOT ERROR:" + e) + } +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..43881a7 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "latex-lsp", + "module": "index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/parser.ts b/parser.ts new file mode 100644 index 0000000..1682911 --- /dev/null +++ b/parser.ts @@ -0,0 +1,474 @@ +import fs from 'fs'; + +import process from 'process'; +import { Severity, type Dialog } from './lsp'; + + +type ParseResultConversion = { + length: number, + original_length?: number, + original_position: number, + position: number, + type: 'text' | 'h1' | 'h2' +}; + +type ParseResult = { + text: string, + originalString: string, + conversions: ParseResultConversion[], +} + +// Returns the number of character skiped +// This puts the i at the \n so it's skiped +function parseComment(text: string, curPos: number): number { + for (let i = curPos + 1; i < text.length; i++) { + const char = text[i]; + if (char === '\n') { + return i - curPos; + } + } + return text.length - curPos; +} + +function createPartition(text: string, startPos: number, curPos: number, result: string): [ParseResultConversion[], string] | null { + if (startPos >= curPos || text.substring(startPos, curPos).match(/^\s*$/)) { + return null; + } + + var t = text.substring(startPos, curPos); + + if (t.indexOf('\n') == -1) { + return [[{ + length: curPos - startPos, + position: result.length, + original_position: startPos, + type: 'text', + }], t]; + } + + const split = t.split('\n'); + + let nt = ""; + const convs: ParseResultConversion[] = []; + + let n = startPos; + + let pos = result.length; + + for (const line of split) { + let nLine = line.replace(/^\s*/, ''); + n += line.length - nLine.length; + nt += nLine + '\n'; + convs.push({ + length: nLine.length, + original_position: n, + position: pos, + type: 'text' + }); + pos += nLine.length + 1; + n += nLine.length + 1; + } + + return [convs, nt]; + +} + +function isChar(charCode: number): boolean { + return (charCode >= 92 && charCode <= 122) || (charCode >= 65 && charCode <= 90); +} +function readBalanced(endChar: string, startChar: string, text: string, curPos: number): number { + let bal = 1; + for (let i = curPos; i < text.length; i++) { + const char = text[i]; + if (char == endChar) { + if (bal == 1) { + return i - curPos + 1; + } else { + bal -= 1; + } + } else if (char == startChar) { + bal += 1; + } + } + throw new Error("Can not find end of balance read") +} + +function isWhiteSpace(char: string): boolean { + return [' ', '\t', '\n'].includes(char); +} + +function parseCommand(text: string, curPos: number, result: string): [number, ParseResultConversion, string] | [number] { + + if (text.length - 1 == curPos) { + throw new Error("The latex file has the wrong format the file can not end with a empty command"); + } + + if (text[curPos + 1] === '\\') { + return [1, { + length: 1, + position: result.length, + original_position: curPos + 1, + type: 'text' + }, '\\']; + } else if (text[curPos + 1] === '%') { + return [1, { + length: 1, + position: result.length, + original_position: curPos + 1, + type: 'text' + }, '%']; + } + + let commandName = ""; + let commandNameFinished = false; + + // TODO store the location of the opts and args + let options = []; + let args = []; + + let findEnd = false; + + + let len = 0; + + for (let i = curPos + 1; i < text.length; i++) { + const char = text[i]; + if (isChar(char.charCodeAt(0))) { + if (!commandNameFinished) { + commandName += char; + } else { + len = i; + findEnd = true; + break; + } + } else if (char === '[') { + commandNameFinished = true; + const len = readBalanced(']', '[', text, i + 1); + options.push(text.substring(i + 1, i + len)) + i += len; + } else if (char === '{') { + commandNameFinished = true; + const len = readBalanced('}', '{', text, i + 1); + args.push(text.substring(i + 1, i + len)) + i += len; + } else if (isWhiteSpace(char)) { + len = i; + findEnd = true; + break; + } else { + + if (char == '.' || char == ',') { + if (commandNameFinished) { + len = i; + findEnd = true; + break; + } + } + + console.log(text.substring(i - 20, i + 20)); + console.log('Char:', char.charCodeAt(0)); + throw new Error("TODO handle not char chars in the parse command function"); + } + } + + if (!findEnd) { + throw new Error("Could not end of the command"); + } + + len = len - curPos; + + switch (commandName) { + case 'documentclass': + case 'usepackage': + case 'graphicspath': + case 'hypersetup': + case 'pagestyle': + case 'fancyhead': + case 'fancyfoot': + case 'renewcommand': + case 'setlength': + case 'addbibresource': + case 'date': + case 'maketitle': + case 'newpage': + case 'tableofcontents': + case 'includegraphics': + case 'appendix': + case 'printbibliography': + return [len]; + case 'title': + case 'author': + case 'end': + console.log("TODO: add way to check the", commandName) + return [len]; + + case 'cite': + console.log("TODO check if it exists on the bibliography"); + return [len]; + + case 'begin': + case 'item': + console.log("TODO handle", commandName, ":", args) + return [len]; + + case 'section': + return [len, { + length: args[0].length + 1, + original_length: len, + original_position: curPos - commandName.length, + position: result.length, + type: 'h1', + }, args[0] + '\n'] + + case 'subsection': + return [len, { + length: args[0].length + 1, + original_length: len, + original_position: curPos - commandName.length, + position: result.length, + type: 'h2', + }, args[0] + '\n'] + + default: + console.log("Command name:", commandName, "options:", options, "args:", args); + throw new Error("TODO handle this case"); + } + + +} + +function readUntil(text: string, char: string, curPos: number): number { + for (let i = curPos; i < text.length; i++) { + if (text[i] === char) { + return i - curPos; + } + } + + throw new Error("Could not find matching pair"); +} + +export function parseLsp(text: string): ParseResult { + const result: ParseResult = { + text: '', + originalString: text, + conversions: [], + }; + + let conversionStartPosition = 0; + + for (let i = 0; i < text.length; i++) { + let char = text[i]; + + if (char === '%') { + //console.log("Found comment"); + + const possiblePartition = createPartition(text, conversionStartPosition, i - 1, result.text); + + if (possiblePartition) { + const [conv, toAdd] = possiblePartition; + result.conversions = result.conversions.concat(conv) + result.text += toAdd; + } + + const len = parseComment(text, i); + + i += len; + + // Skip the begining \n + conversionStartPosition = i + 1; + + } else if (char === '\\') { + //console.log("Found command") + + const possiblePartition = createPartition(text, conversionStartPosition, i - 1, result.text); + + if (possiblePartition) { + const [conv, toAdd] = possiblePartition; + result.conversions = result.conversions.concat(conv); + result.text += toAdd; + } + + const res = parseCommand(text, i, result.text); + + if (res.length === 1) { + const [len] = res; + i += len; + } else { + + const [len, conv, toAdd] = res; + + result.conversions.push(conv); + result.text += toAdd; + i += len; + } + + conversionStartPosition = i + 1; + } else if (char === '$') { + console.log('Found math expr') + if (text[i + 1] === '$') { + throw new Error("Handle double math expression"); + } + + const possiblePartition = createPartition(text, conversionStartPosition, i - 1, result.text); + + if (possiblePartition) { + const [conv, toAdd] = possiblePartition; + result.conversions = result.conversions.concat(conv); + result.text += toAdd; + } + + const len = readUntil(text, '$', i + 1); + + i += len + 1; + + conversionStartPosition = i + 1; + } else { + //console.log(char); + } + + } + + return result; +} + +function getLineAndChar(lineIndex: number[], offset: number): [number, number] { + let l = 0; + let r = lineIndex.length; + + while (r >= l) { + const i = Math.floor((r + l) / 2); + //console.log(i, l ,r, offset, lineIndex[i]); + + if (lineIndex[i + 1] < offset) { + l = i + 1; + continue; + } + if (lineIndex[i] > offset) { + r = i - 1; + continue; + } + return [i, offset - lineIndex[i]]; + } + + return [-1, -1]; +} + + +function getOriginalPostion(res: ParseResult, offset: number): number { + let l = 0; + let r = res.conversions.length; + + while (r >= l) { + const i = Math.floor((r + l) / 2); + const conv = res.conversions[i]; + + if (conv.position > offset) { + r = i - 1; + continue; + } + + if (conv.position + conv.length < offset) { + l = i + 1; + continue; + } + + return conv.original_position + (offset - conv.position); + } + + return -1; +} + +function buildLineIndex(file: string) { + const lines = file.split('\n'); + let i = 0; + const lineIndex = [0]; + for (const line of lines) { + i += line.length + 1; + lineIndex.push(i); + } + return lineIndex; +} + +export async function getDiagnostics(file: string): Promise { + + const res = parseLsp(file); + + const formData = new URLSearchParams(); + + formData.set('text', res.text); + formData.set('language', 'en-GB'); + formData.set('username', process.env.USERNAME ?? ''); + formData.set('apiKey', process.env.APIKEY ?? ''); + formData.set('level', 'picky'); + + const rawRes = await fetch('https://api.languagetoolplus.com/v2/check', { + method: 'POST', + headers: { + 'Accept': 'application/json', + }, + body: formData, + }); + + if (rawRes.status !== 200) { + process.exit(2); + } + const body = await rawRes.json(); + + type Match = { + message: string, + shortMessage: string, + offset: number, + length: number, + replacements: { + value: string + }[], + context: { + text: string, + offset: number, + length: number + }, + sentence: string, + rule: { + id: string, + subId: string, + description: string, + urls: { + value: string + }[], + issueType: string, + category: { + id: string, + name: string + } + } + } + + const lineIndex = buildLineIndex(file); + + const diags = []; + + for (const i of body.matches) { + const match: Match = i; + const original_position = getOriginalPostion(res, match.offset); + if (original_position == -1) { + console.log("Could not find the original position") + continue; + } + const [startLine, startChar] = getLineAndChar(lineIndex, original_position); + const [endLine, endChar] = getLineAndChar(lineIndex, original_position + match.length); + + const range = { + start: { line: startLine, character: startChar }, + end: { line: endLine, character: endChar }, + } + + diags.push({ + range, + severity: Severity.Error, + message: match.message, + }) + } + + return diags; +} + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..dcd8fc5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + /* Linting */ + "skipLibCheck": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true + } +}