├── .eslintignore ├── .eslintrc.json ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── demo-media ├── formattedPasteImage.gif ├── icon-wirefrane-minimal.svg ├── icon.svg ├── liveSnippets.gif ├── preview-card.png ├── preview-card.svg ├── tikz-preview.gif └── zotero-integration.gif ├── icon.png ├── package.json ├── resources ├── liveSnippetSchema.json └── liveSnippets.json ├── scripts ├── countword-linux.sh ├── countword-win.bat ├── saveclipimg-linux.sh ├── saveclipimg-mac.applescript └── saveclipimg-pc.ps1 ├── src ├── components │ ├── completionWatcher.ts │ ├── logger.ts │ ├── paster.ts │ ├── typeFinder.ts │ ├── wordCounter.ts │ └── zotero.ts ├── main.ts ├── providers │ └── macroDefinitions.ts ├── utils.ts └── workshop │ ├── LICENSE.txt │ ├── finderutils.ts │ ├── manager.ts │ ├── pathutils.ts │ └── utils.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | src/lib/**/*.ts 2 | resources/**/*.js 3 | out 4 | node_modules 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | // "plugin:@typescript-eslint/recommended-requiring-type-checking" 10 | ], 11 | "globals": { 12 | "Atomics": "readonly", 13 | "SharedArrayBuffer": "readonly" 14 | }, 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaVersion": 2018, 18 | "project": "./tsconfig.json" 19 | }, 20 | "rules": { 21 | "no-undef": "off", 22 | "no-unused-vars": "off", 23 | "no-constant-condition": "off", 24 | "@typescript-eslint/ban-ts-comment": "warn", 25 | "@typescript-eslint/prefer-interface": "off", 26 | "@typescript-eslint/explicit-function-return-type": "off", 27 | "@typescript-eslint/explicit-member-accessibility": "off", 28 | "@typescript-eslint/indent": "off", 29 | "@typescript-eslint/interface-name-prefix": "off", 30 | "@typescript-eslint/naming-convention": [ 31 | "error", 32 | { 33 | "format": [ 34 | "camelCase", 35 | "UPPER_CASE", 36 | "PascalCase" 37 | ], 38 | "leadingUnderscore": "allow", 39 | "selector": "default" 40 | } 41 | ], 42 | "@typescript-eslint/no-use-before-define": "off", 43 | "@typescript-eslint/type-annotation-spacing": [ 44 | "error", 45 | { 46 | "before": false, 47 | "after": true, 48 | "overrides": { 49 | "arrow": { 50 | "before": true, 51 | "after": true 52 | } 53 | } 54 | } 55 | ], 56 | "@typescript-eslint/no-unused-vars": [ 57 | "error", 58 | { 59 | "args": "none" 60 | } 61 | ], 62 | "prefer-arrow-callback": [ 63 | "error", 64 | { 65 | "allowUnboundThis": false 66 | } 67 | ], 68 | "@typescript-eslint/no-parameter-properties": "off", 69 | "@typescript-eslint/no-explicit-any": "off", 70 | "@typescript-eslint/no-inferrable-types": "off", 71 | "@typescript-eslint/prefer-regexp-exec": "off", 72 | "curly": "error", 73 | "eol-last": "error", 74 | "no-caller": "error", 75 | "no-multiple-empty-lines": "error", 76 | "no-new-wrappers": "error", 77 | "no-eval": "error", 78 | "no-invalid-this": "error", 79 | "no-console": "off", 80 | "@typescript-eslint/no-require-imports": "error", 81 | "no-shadow": "off", 82 | "@typescript-eslint/no-shadow": [ 83 | "error" 84 | ], 85 | "no-trailing-spaces": "error", 86 | "no-empty": [ 87 | "error", 88 | { 89 | "allowEmptyCatch": true 90 | } 91 | ], 92 | "no-unused-expressions": "error", 93 | "no-var": "error", 94 | "object-shorthand": "error", 95 | "one-var": [ 96 | "error", 97 | { 98 | "initialized": "never", 99 | "uninitialized": "never" 100 | } 101 | ], 102 | "prefer-const": "error", 103 | "quotes": [ 104 | "error", 105 | "single", 106 | { 107 | "avoidEscape": true 108 | } 109 | ], 110 | "@typescript-eslint/member-delimiter-style": [ 111 | "error", 112 | { 113 | "multiline": { 114 | "delimiter": "none", 115 | "requireLast": false 116 | }, 117 | "singleline": { 118 | "delimiter": "comma", 119 | "requireLast": false 120 | } 121 | } 122 | ], 123 | "default-case": "error", 124 | "eqeqeq": [ 125 | "error", 126 | "always" 127 | ], 128 | "space-before-function-paren": [ 129 | "error", 130 | { 131 | "anonymous": "always", 132 | "named": "never", 133 | "asyncArrow": "always" 134 | } 135 | ], 136 | "func-call-spacing": [ 137 | "error", 138 | "never" 139 | ], 140 | "no-multi-spaces": [ 141 | "error", 142 | { 143 | "ignoreEOLComments": true 144 | } 145 | ], 146 | "@typescript-eslint/no-empty-function": "off" 147 | /* 148 | "align": [true, "parameters", "statements"], 149 | "no-angle-bracket-type-assertion": true, 150 | "no-default-export": true, 151 | "one-line": [ 152 | true, 153 | "check-catch", 154 | "check-else", 155 | "check-finally", 156 | "check-open-brace", 157 | "check-whitespace" 158 | ], 159 | "typedef-whitespace": [ 160 | true, 161 | { 162 | "call-signature": "onespace", 163 | "index-signature": "nospace", 164 | "parameter": "nospace", 165 | "property-declaration": "nospace", 166 | "variable-declaration": "nospace" 167 | }, 168 | { 169 | "call-signature": "onespace", 170 | "index-signature": "onespace", 171 | "parameter": "onespace", 172 | "property-declaration": "onespace", 173 | "variable-declaration": "onespace" 174 | } 175 | ], 176 | "whitespace": [ 177 | true, 178 | "check-branch", 179 | "check-decl", 180 | "check-operator", 181 | "check-separator", 182 | "check-type", 183 | "check-typecast" 184 | ] */ 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 13 | 14 | ## Bug Report 15 | 16 | #### Disable all the other extensions except for LaTeX Workshop and LaTeX Utilities, and check that you still see this issue. 17 | 18 | You still see this issue?: **Yes/No** 19 | 20 | ### Describe the bug 21 | 22 | A clear and concise description of what the bug is. 23 | 24 | ### To Reproduce 25 | 26 | Steps to reproduce the behaviour: 27 | 28 | 1. Go to '...' 29 | 2. Click on '....' 30 | 3. See error 31 | 32 | ### Expected behaviour 33 | 34 | A clear and concise description of what you expected to happen. 35 | 36 | ### Logs 37 | 38 | Please paste the whole log messages here, not parts of ones. It is very important to identify problems. If you think the logs are unrelated, please say so. 39 | 40 |
41 | LaTeX Workshop Output 42 | 43 | 44 | ``` 45 | logs here 46 | ``` 47 | 48 |
49 | 50 |
51 | LaTeX Utilities Output 52 | 53 | 54 | ``` 55 | logs here 56 | ``` 57 | 58 |
59 | 60 |
61 | Developer Tools Console 62 | 63 | 64 | ``` 65 | logs here 66 | ``` 67 | 68 |
69 | 70 | ### Screenshots 71 | 72 | If applicable, add screenshots to help explain your problem. 73 | 74 | ### Desktop 75 | 76 | - OS: Windows 10 / MacOS / Linux 77 | - VS Code version: X.Y.Z 78 | - Extension version: X.Y.Z 79 | 80 | ### Additional context 81 | 82 | Add any other context about the problem here. 83 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Feature Request 11 | 12 | ### Is your feature request related to a problem? Please describe. 13 | 14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when... 15 | 16 | ### Describe the solution you'd like 17 | 18 | A clear and concise description of what you want to happen. 19 | 20 | ### Describe alternatives you've considered 21 | 22 | A clear and concise description of any alternative solutions or features you've considered. 23 | 24 | ### Additional context 25 | 26 | Add any other context or screenshots about the feature request here. 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test/ 4 | *.vsix 5 | VERSION 6 | .vscode/* 7 | !.vscode/settings.json 8 | !.vscode/tasks.json 9 | !.vscode/launch.json 10 | !.vscode/extensions.json 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | - repo: https://github.com/eslint/eslint 12 | rev: "v8.48.0" # Use the sha / tag you want to point at 13 | hooks: 14 | - id: eslint 15 | files: \.[jt]sx?$ # *.js, *.jsx, *.ts and *.tsx 16 | types: [file] 17 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "James-Yu.latex-workshop"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": [ 14 | "--extensionDevelopmentPath=${workspaceFolder}" 15 | ], 16 | "outFiles": [ 17 | "${workspaceFolder}/out/**/*.js" 18 | ], 19 | "preLaunchTask": "npm: esbuild-watch" 20 | }, 21 | { 22 | "name": "Extension Tests", 23 | "type": "extensionHost", 24 | "request": "launch", 25 | "runtimeExecutable": "${execPath}", 26 | "args": [ 27 | "--extensionDevelopmentPath=${workspaceFolder}", 28 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 29 | ], 30 | "outFiles": [ 31 | "${workspaceFolder}/out/test/**/*.js" 32 | ], 33 | "preLaunchTask": "npm: esbuild-watch" 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "out": true, 4 | "node_modules": true 5 | }, 6 | "search.exclude": { 7 | "out": true 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off", 11 | // "eslint.workingDirectories": [], 12 | "eslint.provideLintTask": true, 13 | "eslint.validate": ["typescript"] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "esbuild-watch", 9 | "problemMatcher": "$esbuild-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode-test/** 2 | out/test/** 3 | test/** 4 | **/*.map 5 | .gitignore 6 | tsconfig.json 7 | tsconfig.eslint.json 8 | demo_media/** 9 | .eslintcache 10 | .eslintignore 11 | *.vsix 12 | .vscode 13 | node_modules 14 | src/ 15 | tsconfig.json 16 | webpack.config.js 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [0.4.14] - 2024-03-25 4 | ### Improved 5 | - [#356](https://github.com/tecosaur/LaTeX-Utilities/issues/356) Allow setting more language for live reformat 6 | 7 | ## [0.4.13] - 2024-02-13 8 | ### Fixed 9 | - [#397](https://github.com/tecosaur/LaTeX-Utilities/issues/397) Fix configuration name 10 | 11 | 12 | ## [0.4.12] - 2024-02-11 13 | ### Fixed 14 | - [#387](https://github.com/tecosaur/LaTeX-Utilities/issues/387) Add support for R Sweave and Julia Sweave Dcouemnts 15 | - [#389](https://github.com/tecosaur/LaTeX-Utilities/issues/389) Fixes typo 16 | - [#391](https://github.com/tecosaur/LaTeX-Utilities/issues/391) Updated readme to specify how to install texcount 17 | - [#394](https://github.com/tecosaur/LaTeX-Utilities/issues/394) migrate live snippets template to VSCode's internal config 18 | - [#396](https://github.com/tecosaur/LaTeX-Utilities/issues/396) support for searching authors when using vscode as zotero search method 19 | 20 | ## [0.4.11] - 2023-4-3 21 | ### Fixed 22 | - [#383](https://github.com/tecosaur/LaTeX-Utilities/issues/383) Fixes an issue that caused formatted pasting can't paste image on linux. 23 | 24 | ## [0.4.10] - 2023-2-22 25 | ### Added 26 | - Add an option `imagePathOverride` to override the image path in formatted pasting. 27 | 28 | ## [0.4.9] - 2022-12-16 29 | ### Added 30 | - Config to use docker to run texcount 31 | 32 | ## [0.4.8] - 2022-10-16 33 | ### Added 34 | - Support character count 35 | 36 | ## [0.4.7] - 2022-09-25 37 | ### Added 38 | - Support pasting image file in formatted paste 39 | 40 | ## [0.4.6] - 2022-08-06 41 | ### Fixed 42 | - Warn users about JSON errors in snippet file. 43 | 44 | ## [0.4.5] - 2022-07-31 45 | ### Fixed 46 | - Fix bugs introduced in 0.4.3 47 | 48 | ## [0.4.4] - 2022-07-31 49 | ### Improved 50 | - Prompt user when TexDef is not installed 51 | 52 | ## [0.4.3] - 2022-07-30 53 | ### Improved 54 | - Add error telemetry 55 | 56 | ## [0.4.2] - 2022-07-29 57 | 58 | ### Fixed 59 | - Wrong path introduced by esbuild. 60 | 61 | ## [0.4.1] - 2022-07-17 62 | 63 | ### Improved 64 | - Use esbuild to bundle the package to improve performance. 65 | 66 | ## [0.4.0] - 2022-07-06 67 | New maintainer! 68 | - Removed dependency on LaTeX-Workshop. 69 | - Removed support for TiKZ live preview temporarily. 70 | - Fixed pasting image inside WSL. 71 | - Move from NPM to yarn. 72 | 73 | ## [0.3.7] — 2020-05-22 74 | 75 | Announce that extension is no longer maintained. 76 | 77 | ## [0.3.6] — 2020-02-01 78 | 79 | ### Fixed 80 | 81 | - API changed in `got` breaking Zotero citation search. 82 | 83 | ## [0.3.5] — 2020-02-01 84 | 85 | ### Added 86 | 87 | - Command to reset user live snippets file 88 | - Command to compare user live snippets file to default 89 | - Basic support for bulleted lists in formatted paste 90 | - Notification on extension update 91 | - Notify users when save/close a user live snippets file same as the extension default 92 | 93 | ### Improved 94 | 95 | - Tweak default live snippets (yet again, again) 96 | - Account for indent when formatted-pasting text 97 | - Try to avoid plaintext 🡒 LaTeX formatting already LaTeX formatted pastes 98 | - Determination of maths/text type at cursor gets it wrong a bit less 99 | 100 | ### Fixed 101 | 102 | - Formatted pasting a single line of text with cursor at non-zero column resulted in text being cut out 103 | - Account for inconsistency in `texcount` output 104 | - Add `\pgfplotsset{table/search path=...` to TikZ Preview to (hopefully) fix local file references 105 | 106 | ## [0.3.4] — 2019-11-02 107 | 108 | ### Added 109 | 110 | - TikZ Preview `timeout` setting, to ignore the first change made after a certain period 111 | - JSON validation schema for live snippets file 112 | 113 | ### Improved 114 | 115 | - TikZ Preview now uses relevant lines prior to the `tikzpicture` 116 | - Make TikZ Preview work with any environment which matches `\w*tikz\w*` 117 | - Live snippets now treats comments inside a math environment as "text" 118 | - Lots of excess logging with live snippets removed 119 | - More tweaks to live snippets 120 | - Make formatted paste line shaping account for current column 121 | 122 | ### Fixed 123 | 124 | - TikZ Preview delay was dodgy 125 | - TeX Count 'results' line was incorrectly isolated 126 | 127 | ## [0.3.3] — 2019-10-19 128 | 129 | ### Added 130 | 131 | - Setting to make formatted paste the default (`ctrl`+`v`) paste 132 | - New setting for custom delimiter for formatted paste to try with tables 133 | - Telemetry to try to help direct development effort 134 | 135 | ### Improved 136 | 137 | - More tweaks to live snippets (`sr`, `cb` and superscripts) 138 | - Formatted paste of tables now 'just works' with anything which is tab, comma, or `|` delimited, 139 | i.e. spreadsheets, csv, markdown 140 | - Formatted paste of text now joins hyphenated words 141 | 142 | ## [0.3.2] — 2019-10-16 143 | 144 | ### Added 145 | 146 | - Customisable wordcount status 147 | - Formatted paste now shapes text to (configurable) line length 148 | 149 | ### Improved 150 | 151 | - Live snippets now do so more accents, and spaces 152 | 153 | ### Fixed 154 | 155 | - Live snippets can no longer do dodgy stuff when the replacement is the same length as the original 156 | 157 | ## [0.3.1] — 2019-09-27 158 | 159 | ### Improved 160 | 161 | - Live snippets are now a bit better again (see #42) 162 | 163 | ### Fixed 164 | 165 | - Some formatted-paste text replacements 166 | - LiveSnippets now recognise placeholder tabstops 167 | 168 | ## [0.3.0] — 2019-09-06 169 | 170 | ### Added 171 | 172 | - Add Zotero integration with BBT (Better BibTeX) 173 | 174 | ### Improved 175 | 176 | - Change placeholder style in snippets from `$.` to `$$`, because it seems cleaner. 177 | 178 | ### Fixed 179 | 180 | - Some of the default live snippets were a bit dodgy 181 | - Fixed #17 (cursor moving backwards too far with some live snippets) 182 | - TikZ Preview no longer grabs lines after `\begin{document}` 183 | - Fix up some of the text replacements done by formatted paste 184 | 185 | ## [0.2.2] — 2019-08-23 186 | 187 | ### Added 188 | 189 | - Toggle for the define command with `texdef` feature 190 | 191 | ## [0.2.1] — 2019-08-19 192 | 193 | ### Fixed 194 | 195 | - Demo images on marketplace page 196 | 197 | ## [0.2.0] — 2019-08-19 198 | 199 | ### Added 200 | 201 | - Word Count 202 | - TikZ Preview 203 | - Adds a code lense above `\begin{tikzpicture}` that allows for live previewing 204 | - Command Definitions 205 | 206 | ### Improved 207 | 208 | - Full formatted paste features, `ctrl`+`shift`+`v` 209 | 210 | - Reformats some Unicode text for LaTeX 211 | - Table cells are turned into a `tabular` 212 | - Pasting the location of a `.csv` file pastes a table with the contents 213 | - Paste an image from the clipboard (as in 0.1.0) 214 | - Formatted paste the path to a csv (adds tabular) or image file (links to file) 215 | 216 | - Live Snippets: added more mathematics environments to environment (text/maths) detection code 217 | - Live Snippets: may now be _marginally_ faster due to some behind-the-scenes reworking 218 | 219 | ### Fixed 220 | 221 | - Live Snippets: Big! bug with environment (text/maths) detection code 222 | 223 | ## [0.1.0] — 2019-07-31 224 | 225 | ### Added 226 | 227 | - Image Pasting (via `ctrl`+`shift`+`v` and "Paste an Image File") 228 | - Live Snippets (auto-activating, with regex) 229 | 230 | [unreleased]: https://github.com/tecosaur/latex-utilities/compare/v0.4.6...HEAD 231 | [0.4.6]: https://github.com/tecosaur/latex-utilities/compare/v0.4.5...v0.4.6 232 | [0.4.5]: https://github.com/tecosaur/latex-utilities/compare/v0.4.4...v0.4.5 233 | [0.4.4]: https://github.com/tecosaur/latex-utilities/compare/v0.4.3...v0.4.4 234 | [0.4.3]: https://github.com/tecosaur/latex-utilities/compare/v0.4.2...v0.4.3 235 | [0.4.2]: https://github.com/tecosaur/latex-utilities/compare/v0.4.1...v0.4.2 236 | [0.4.1]: https://github.com/tecosaur/latex-utilities/compare/v0.4.0...v0.4.1 237 | [0.4.0]: https://github.com/tecosaur/latex-utilities/compare/v0.3.7...v0.4.0 238 | [0.3.7]: https://github.com/tecosaur/latex-utilities/compare/v0.3.5...v0.3.7 239 | [0.3.6]: https://github.com/tecosaur/latex-utilities/compare/v0.3.5...v0.3.6 240 | [0.3.5]: https://github.com/tecosaur/latex-utilities/compare/v0.3.4...v0.3.5 241 | [0.3.4]: https://github.com/tecosaur/latex-utilities/compare/v0.3.3...v0.3.4 242 | [0.3.3]: https://github.com/tecosaur/latex-utilities/compare/v0.3.2...v0.3.3 243 | [0.3.2]: https://github.com/tecosaur/latex-utilities/compare/v0.3.1...v0.3.2 244 | [0.3.1]: https://github.com/tecosaur/latex-utilities/compare/v0.3.0...v0.3.1 245 | [0.3.0]: https://github.com/tecosaur/latex-utilities/compare/v0.2.2...v0.3.0 246 | [0.2.2]: https://github.com/tecosaur/latex-utilities/compare/v0.2.1...v0.2.2 247 | [0.2.1]: https://github.com/tecosaur/latex-utilities/compare/v0.2.0...v0.2.1 248 | [0.2.0]: https://github.com/tecosaur/latex-utilities/compare/v0.1.0...v0.2.0 249 | [0.1.0]: https://github.com/tecosaur/latex-utilities/compare/bc5bf4f...v0.1.0 250 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | ## 1. Install 4 | 5 | `git clone https://github.com/tecosaur/LaTeX-Utilities.git`, 6 | 7 | ## 2. Initialise 8 | 9 | Open the `LaTeX-Utilities` directory in vscode. Open up the command line and run `yarn` 10 | 11 | ## 3. Make a new branch and add commits 12 | 13 | If you can make a PR, that's fantastic. You just have to be ok with your code going under MIT. 14 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 tecosaur 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

LaTeX Utils logo

2 | 3 |

4 | 5 | 6 | version 7 | 8 | downloads 9 | 10 | installs 11 | 12 | rating 13 | 14 |
15 | 16 | 17 | CodeFactor 18 | 19 | GitHub issues 20 | 21 | GitHub last commit 22 | 23 | 24 | license 25 | 26 |

27 | 28 |

LaTeX Utilities

29 | 30 | An add-on to the vscode extension [LaTeX Workshop](https://marketplace.visualstudio.com/items?itemName=James-Yu.latex-workshop) that provides some fancy features that are less vital to the basic experience editing a LaTeX document, but can be rather nice to have. 31 | The feature should continue to expand at a gradually decreasing rate. 32 | 33 | Got an idea? Make a PR! 34 | 35 |
36 | 37 | ## Features 38 | 39 | - Formatted Pastes 40 | - Unicode characters 🡒 LaTeX characters (e.g. `“is this… a test”` 🡒 ` ``is this\ldots a test'' `) 41 | - Paste table cells (from spreadsheet programs or similar) 🡒 tabular 42 | - Paste images, customisable template 43 | - Paste location of CSVs/images to have them included 44 | - Live Snippets (auto-activating, with regex) [see here](https://github.com/tecosaur/LaTeX-Utilities/wiki/Live-Snippets) for documentation 45 | - Word count in status bar 46 | - Zotero citation management 47 | 48 | ## Documentation 49 | 50 | - See the [wiki](https://github.com/tecosaur/LaTeX-Utilities/wiki) 51 | 52 | ## Requirements 53 | 54 | - A LaTeX installation in your path 55 | - The [`texcount`](https://app.uio.no/ifi/texcount/) script (only necessary for the word-count function). Configure using the `latex-utilities.countWord.path` and `latex-utilities.countWord.args` settings. 56 | - Alternatively, install the `texcount` package from your TeX package manager (e.g., `tlmgr`) if it doesn't come with your TeX distribution. 57 | - Zotero with the [Better BibTeX extension](https://retorque.re/zotero-better-bibtex/) (only necessary for Zotero 58 | functions). 59 | 60 | ## Demos 61 | 62 | ### Formatted Paste (image) 63 | 64 | 65 | 66 | ### Live Snippets 67 | 68 | 69 | 70 | ### Zotero Integration 71 | 72 | 73 | 74 |
75 |
76 | 77 | --- 78 | 79 | ## Telemetry 80 | 81 | ### Why 82 | 83 | As a bunch of fancy, but non-essential features, it can be hard to know what features users actually derive value from. 84 | In adding telemetry to this extension I hope to get an idea of this, and inform future development efforts. 85 | It should also be possible to report errors in the background, and so I also hope this extension will be more stable as a result. 86 | 87 | At the moment I'm just logging when one of the main features is used. 88 | 89 | **TLDR; I want to get around the 1% rule** 90 | 91 | ### I hate telemetry, go away! 92 | 93 | You probably have disabled vscode's `telemetry.enableTelemetry` then, in which case no telemetry is done. 94 | -------------------------------------------------------------------------------- /demo-media/formattedPasteImage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tecosaur/LaTeX-Utilities/a3265cc6827bd383eac4945ea8b914c0b4896fa0/demo-media/formattedPasteImage.gif -------------------------------------------------------------------------------- /demo-media/icon-wirefrane-minimal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 47 | 49 | 50 | 52 | image/svg+xml 53 | 55 | 56 | 57 | 58 | 59 | 64 | 68 | 75 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /demo-media/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 47 | 49 | 50 | 52 | image/svg+xml 53 | 55 | 56 | 57 | 58 | 59 | 64 | 68 | 75 | 81 | 82 | 86 | 93 | 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /demo-media/liveSnippets.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tecosaur/LaTeX-Utilities/a3265cc6827bd383eac4945ea8b914c0b4896fa0/demo-media/liveSnippets.gif -------------------------------------------------------------------------------- /demo-media/preview-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tecosaur/LaTeX-Utilities/a3265cc6827bd383eac4945ea8b914c0b4896fa0/demo-media/preview-card.png -------------------------------------------------------------------------------- /demo-media/tikz-preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tecosaur/LaTeX-Utilities/a3265cc6827bd383eac4945ea8b914c0b4896fa0/demo-media/tikz-preview.gif -------------------------------------------------------------------------------- /demo-media/zotero-integration.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tecosaur/LaTeX-Utilities/a3265cc6827bd383eac4945ea8b914c0b4896fa0/demo-media/zotero-integration.gif -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tecosaur/LaTeX-Utilities/a3265cc6827bd383eac4945ea8b914c0b4896fa0/icon.png -------------------------------------------------------------------------------- /resources/liveSnippetSchema.json: -------------------------------------------------------------------------------- 1 | { 2 | "definitions": {}, 3 | "$schema": "http://json-schema.org/draft-07/schema#", 4 | "type": "array", 5 | "title": "Set of test strings and replacements", 6 | "items": { 7 | "$id": "#/items", 8 | "type": "object", 9 | "title": "Completion Item", 10 | "required": ["prefix", "body"], 11 | "properties": { 12 | "prefix": { 13 | "$id": "#/items/properties/prefix", 14 | "type": "string", 15 | "title": "Escaped RegEx test string", 16 | "default": "$", 17 | "examples": ["([A-Za-z}\\)\\]])(\\d)$"], 18 | "pattern": "\\$$" 19 | }, 20 | "body": { 21 | "$id": "#/items/properties/body", 22 | "type": "string", 23 | "title": "Replacement text", 24 | "description": "Use $1 for regex groups, and $$1 for tabstops", 25 | "default": "", 26 | "examples": ["$1_$2"] 27 | }, 28 | "mode": { 29 | "$id": "#/items/properties/mode", 30 | "type": "string", 31 | "title": "Specific LaTeX mode. 'any' by default", 32 | "enum": ["maths", "text", "any"], 33 | "default": "any", 34 | "examples": ["maths"] 35 | }, 36 | "triggerWhenComplete": { 37 | "$id": "#/items/properties/triggerWhenComplete", 38 | "type": "boolean", 39 | "title": "Insta-complete. Off by default", 40 | "default": false, 41 | "examples": [true] 42 | }, 43 | "description": { 44 | "$id": "#/items/properties/description", 45 | "type": "string", 46 | "title": "Just a nice description to remember this by", 47 | "default": "", 48 | "examples": ["auto subscript"] 49 | }, 50 | "priority": { 51 | "$id": "#/items/properties/priority", 52 | "type": "number", 53 | "title": "Higher priority snippets get considered first", 54 | "default": 0, 55 | "examples": [1] 56 | }, 57 | "noPlaceholders": { 58 | "$id": "#/items/properties/noPlaceholders", 59 | "type": "boolean", 60 | "title": "For rare cases explicitly specify whether the item has placeholders.", 61 | "description": "This is intelligently worked out by default" 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /resources/liveSnippets.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "prefix": "([A-Za-z}\\)\\]])(\\d)$", 4 | "body": "$1_$2", 5 | "mode": "maths", 6 | "triggerWhenComplete": true, 7 | "description": "auto subscript" 8 | }, 9 | { 10 | "prefix": "([A-Za-z}\\)\\]]) ?_(\\d\\d)$", 11 | "body": "$1_{$2}", 12 | "mode": "maths", 13 | "triggerWhenComplete": true, 14 | "description": "auto escape subscript" 15 | }, 16 | { 17 | "prefix": "(\\S) ([\\^_])$", 18 | "body": "$1$2", 19 | "mode": "maths", 20 | "triggerWhenComplete": true, 21 | "description": "remove extraneous sub/superscript space", 22 | "priority": 2 23 | }, 24 | { 25 | "prefix": "([A-Za-z}\\)\\]]) ?\\^ ?(\\d\\d|[\\+\\-] ?(?:\\d|[A-Za-z]|\\\\\\w+))$", 26 | "body": "$1^{$2}", 27 | "mode": "maths", 28 | "triggerWhenComplete": true, 29 | "description": "auto escape superscript", 30 | "priority": 2 31 | }, 32 | { 33 | "prefix": "([^ &\\\\\\+\\-=<>\\|!~@])([\\+\\-=<>])$", 34 | "body": "$1 $2", 35 | "mode": "maths", 36 | "priority": -1, 37 | "description": "whitespace before operators", 38 | "triggerWhenComplete": true 39 | }, 40 | { 41 | "prefix": "([\\+\\-=<>])([^ &\\\\\\+\\-=<>\\|!~])$", 42 | "body": "$1 $2", 43 | "mode": "maths", 44 | "priority": -1, 45 | "description": "whitespace after operators", 46 | "triggerWhenComplete": true 47 | }, 48 | { 49 | "prefix": "\\.\\.\\.$", 50 | "body": "\\dots ", 51 | "mode": "maths", 52 | "description": "⋯", 53 | "triggerWhenComplete": true 54 | }, 55 | { 56 | "prefix": "=>$", 57 | "body": "\\implies ", 58 | "mode": "maths", 59 | "description": "⇒", 60 | "triggerWhenComplete": true 61 | }, 62 | { 63 | "prefix": "=<$", 64 | "body": "\\impliedby ", 65 | "mode": "maths", 66 | "description": "implied by", 67 | "triggerWhenComplete": true 68 | }, 69 | { 70 | "prefix": "//$", 71 | "body": "\\frac{$$1}{$$2} ", 72 | "mode": "maths", 73 | "description": "fraction (empty)", 74 | "triggerWhenComplete": true 75 | }, 76 | { 77 | "prefix": "(([\\d\\.]+)|([\\d\\.]*)(\\\\)?([A-Za-z]+)((\\^|_)(\\{\\d+\\}|\\d|[A-Za-z]|\\\\\\w+))*!?)\\/$", 78 | "body": "\\frac{$1}{$$1}$$0", 79 | "mode": "maths", 80 | "description": "fraction (from regex)", 81 | "triggerWhenComplete": true 82 | }, 83 | { 84 | "prefix": "([\\)\\]}]) ?/$", 85 | "body": "SPECIAL_ACTION_FRACTION", 86 | "mode": "maths", 87 | "description": "fraction (parsed)", 88 | "triggerWhenComplete": true, 89 | "noPlaceholders": false 90 | }, 91 | { 92 | "prefix": "sympy$", 93 | "body": "sympy $$1 sympy", 94 | "mode": "maths", 95 | "description": "sympy block", 96 | "triggerWhenComplete": false 97 | }, 98 | { 99 | "prefix": "sympy.+$", 100 | "body": "SPECIAL_ACTION_BREAK", 101 | "mode": "maths", 102 | "triggerWhenComplete": true, 103 | "priority": 2 104 | }, 105 | { 106 | "prefix": "sympy ?(.+?) ?sympy ?$", 107 | "body": "SPECIAL_ACTION_SYMPY", 108 | "mode": "maths", 109 | "priority": 3, 110 | "description": "sympy", 111 | "triggerWhenComplete": true 112 | }, 113 | { 114 | "prefix": "(^|[^\\\\])\\biff$", 115 | "body": "$1\\iff ", 116 | "mode": "maths", 117 | "description": "⇔", 118 | "triggerWhenComplete": true 119 | }, 120 | { 121 | "prefix": "(^|[^\\\\])\\binn$", 122 | "body": "$1\\in ", 123 | "mode": "maths", 124 | "description": "in", 125 | "triggerWhenComplete": true 126 | }, 127 | { 128 | "prefix": "(^|[^\\\\])\\bnotin$", 129 | "body": "$1\\not\\in ", 130 | "mode": "maths", 131 | "description": "∈", 132 | "triggerWhenComplete": true 133 | }, 134 | { 135 | "prefix": " ?!=$", 136 | "body": " \\neq ", 137 | "mode": "maths", 138 | "description": "neq", 139 | "triggerWhenComplete": true 140 | }, 141 | { 142 | "prefix": "==$", 143 | "body": "&= ", 144 | "mode": "maths", 145 | "description": "aligned equal", 146 | "priority": 1, 147 | "triggerWhenComplete": true 148 | }, 149 | { 150 | "prefix": " ?~=$", 151 | "body": " \\approx ", 152 | "mode": "maths", 153 | "description": "≈", 154 | "triggerWhenComplete": true 155 | }, 156 | { 157 | "prefix": " ?~~$", 158 | "body": " \\sim ", 159 | "mode": "maths", 160 | "description": "∼", 161 | "triggerWhenComplete": true 162 | }, 163 | { 164 | "prefix": " ?>=$", 165 | "body": " \\geq ", 166 | "mode": "maths", 167 | "description": "≥", 168 | "triggerWhenComplete": true 169 | }, 170 | { 171 | "prefix": " ?<=$", 172 | "body": " \\leq ", 173 | "mode": "maths", 174 | "description": "≤", 175 | "triggerWhenComplete": true 176 | }, 177 | { 178 | "prefix": " ?>>$", 179 | "body": " \\gg ", 180 | "mode": "maths", 181 | "description": "≫", 182 | "triggerWhenComplete": true 183 | }, 184 | { 185 | "prefix": " ?<<$", 186 | "body": " \\ll ", 187 | "mode": "maths", 188 | "description": "≪", 189 | "triggerWhenComplete": true 190 | }, 191 | { 192 | "prefix": " ?xx$", 193 | "body": " \\times ", 194 | "mode": "maths", 195 | "description": "×", 196 | "triggerWhenComplete": true 197 | }, 198 | { 199 | "prefix": " ?\\*\\*$", 200 | "body": " \\cdot ", 201 | "mode": "maths", 202 | "description": "⋅", 203 | "triggerWhenComplete": true 204 | }, 205 | { 206 | "prefix": "(^|[^\\\\]\\b|[ ,\\)\\]\\}]\\w*)(to|->)$", 207 | "body": "$1\\to ", 208 | "mode": "maths", 209 | "description": "→", 210 | "triggerWhenComplete": true 211 | }, 212 | { 213 | "prefix": " ?(?:\\|->|!>)$", 214 | "body": " \\mapsto ", 215 | "mode": "maths", 216 | "description": "↦", 217 | "priority": 1.1, 218 | "triggerWhenComplete": true 219 | }, 220 | { 221 | "prefix": "(^|[^\\\\])a(?:rc)?(sin|cos|tan|cot|csc|sec)$", 222 | "body": "$1\\arc$2 ", 223 | "mode": "maths", 224 | "description": "arc(trig)", 225 | "triggerWhenComplete": true 226 | }, 227 | { 228 | "prefix": "(^|[^\\\\])(sin|cos|tan|cot|csc|sec|min|max|log|exp)$", 229 | "body": "$1\\$2 ", 230 | "mode": "maths", 231 | "description": "un-backslashed operator", 232 | "triggerWhenComplete": true 233 | }, 234 | { 235 | "prefix": "(^|[^\\\\])(pi)$", 236 | "body": "$1\\$2", 237 | "mode": "maths", 238 | "description": "pi", 239 | "triggerWhenComplete": true 240 | }, 241 | { 242 | "prefix": "((?:\\b|\\\\)\\w{1,7})(,\\.|\\.,)$", 243 | "body": "\\vec{$1}", 244 | "mode": "maths", 245 | "description": "vector", 246 | "triggerWhenComplete": true 247 | }, 248 | { 249 | "prefix": "(\\\\?[\\w\\^]{1,7})~ $", 250 | "body": "\\tilde{$1}", 251 | "mode": "maths", 252 | "description": "tilde", 253 | "triggerWhenComplete": true 254 | }, 255 | { 256 | "prefix": "(\\\\?[\\w\\^]{1,7})\\. $", 257 | "body": "\\dot{$1}", 258 | "mode": "maths", 259 | "description": "dot", 260 | "triggerWhenComplete": true 261 | }, 262 | { 263 | "prefix": "(\\\\?[\\w\\^]{1,7})\\.\\. $", 264 | "body": "\\ddot{$1}", 265 | "mode": "maths", 266 | "description": "ddot", 267 | "triggerWhenComplete": true 268 | }, 269 | { 270 | "prefix": "\\bbar$", 271 | "body": "\\overline{$$1}", 272 | "mode": "maths", 273 | "description": "overline", 274 | "triggerWhenComplete": true 275 | }, 276 | { 277 | "prefix": "\\b(\\\\?[\\w\\^{}]{1,3})bar$", 278 | "body": "\\overline{$1}", 279 | "mode": "maths", 280 | "description": "overline", 281 | "triggerWhenComplete": true 282 | }, 283 | { 284 | "prefix": "(^|[^\\\\])\\bhat$", 285 | "body": "$1\\hat{$$1}", 286 | "mode": "maths", 287 | "description": "hat", 288 | "triggerWhenComplete": true 289 | }, 290 | { 291 | "prefix": "\\b([\\w\\^{}])hat$", 292 | "body": "\\hat{$1}", 293 | "mode": "maths", 294 | "description": "hat", 295 | "triggerWhenComplete": true 296 | }, 297 | { 298 | "prefix": "\\\\\\)(\\w)$", 299 | "body": "\\) $1", 300 | "mode": "any", 301 | "description": "space after inline maths", 302 | "triggerWhenComplete": true 303 | }, 304 | { 305 | "prefix": "\\\\\\\\\\\\$", 306 | "body": "\\setminus ", 307 | "mode": "maths", 308 | "description": "∖ (setminus)", 309 | "triggerWhenComplete": true 310 | }, 311 | { 312 | "prefix": "\\bpmat$", 313 | "body": "\\begin{pmatrix} $$1 \\end{pmatrix} ", 314 | "mode": "maths", 315 | "description": "pmatrix", 316 | "triggerWhenComplete": true 317 | }, 318 | { 319 | "prefix": "\\bbmat$", 320 | "body": "\\begin{bmatrix} $$1 \\end{bmatrix} ", 321 | "mode": "maths", 322 | "description": "bmatrix", 323 | "triggerWhenComplete": true 324 | }, 325 | { 326 | "prefix": "\\bpart$", 327 | "body": "\\frac{\\partial $${1:V}}{\\partial $${2:x}} ", 328 | "mode": "maths", 329 | "description": "partial derivative", 330 | "triggerWhenComplete": true 331 | }, 332 | { 333 | "prefix": "\\bsq$", 334 | "body": "\\sqrt{$$1}", 335 | "mode": "maths", 336 | "description": "√", 337 | "triggerWhenComplete": true 338 | }, 339 | { 340 | "prefix": " ?sr$", 341 | "body": "^2", 342 | "mode": "maths", 343 | "description": "²", 344 | "triggerWhenComplete": true 345 | }, 346 | { 347 | "prefix": " ?cb$", 348 | "body": "^3", 349 | "mode": "maths", 350 | "description": "³", 351 | "triggerWhenComplete": true 352 | }, 353 | { 354 | "prefix": "\\bEE$", 355 | "body": "\\exists ", 356 | "mode": "maths", 357 | "description": "∃", 358 | "triggerWhenComplete": true 359 | }, 360 | { 361 | "prefix": "\\bAA$", 362 | "body": "\\forall ", 363 | "mode": "maths", 364 | "description": "∀", 365 | "triggerWhenComplete": true 366 | }, 367 | { 368 | "prefix": "\\b([A-Za-z])([A-Za-z])\\2$", 369 | "body": "$1_$2", 370 | "mode": "maths", 371 | "description": "subscript letter", 372 | "triggerWhenComplete": true 373 | }, 374 | { 375 | "prefix": "\\b([A-Za-z])([A-Za-z])\\2?p1$", 376 | "body": "$1_{$2+1}", 377 | "mode": "maths", 378 | "description": "subscript letter + 1", 379 | "priority": 2, 380 | "triggerWhenComplete": true 381 | }, 382 | { 383 | "prefix": "\\bdint$", 384 | "body": "\\int_{$${1:-\\infty}}^{$${2:\\infty}} ", 385 | "mode": "maths", 386 | "description": "∫ₐᵇ", 387 | "triggerWhenComplete": true 388 | }, 389 | { 390 | "prefix": "([^ \\\\]) $", 391 | "body": "$1\\, ", 392 | "mode": "maths", 393 | "description": "add maths whitespace \\,", 394 | "priority": -1, 395 | "triggerWhenComplete": true 396 | }, 397 | { 398 | "prefix": "([^ \\\\])\\\\, {2,4}$", 399 | "body": "$1\\: ", 400 | "mode": "maths", 401 | "description": "add maths whitespace \\:", 402 | "priority": 0.1, 403 | "triggerWhenComplete": true 404 | }, 405 | { 406 | "prefix": "([^ \\\\])\\\\: {2,4}$", 407 | "body": "$1\\; ", 408 | "mode": "maths", 409 | "description": "add maths whitespace \\;", 410 | "priority": 0.2, 411 | "triggerWhenComplete": true 412 | }, 413 | { 414 | "prefix": "([^ \\\\])\\\\; {2,4}$", 415 | "body": "$1\\ ", 416 | "mode": "maths", 417 | "description": "add maths whitespace \\ ", 418 | "priority": 0.3, 419 | "triggerWhenComplete": true 420 | }, 421 | { 422 | "prefix": "([^ \\\\])\\\\ {2,4}$", 423 | "body": "$1\\quad ", 424 | "mode": "maths", 425 | "description": "add maths whitespace quad", 426 | "priority": 0.4, 427 | "triggerWhenComplete": true 428 | }, 429 | { 430 | "prefix": "([^ \\\\])\\\\quad {2,4}$", 431 | "body": "$1\\qquad ", 432 | "mode": "maths", 433 | "description": "add maths whitespace qquad", 434 | "priority": 0.5, 435 | "triggerWhenComplete": true 436 | }, 437 | { 438 | "prefix": "\\bset$", 439 | "body": "\\\\{$$1\\\\} ", 440 | "mode": "maths", 441 | "description": "set {}", 442 | "triggerWhenComplete": true 443 | }, 444 | { 445 | "prefix": " ?\\|\\|$", 446 | "body": " \\mid ", 447 | "mode": "maths", 448 | "description": "∣", 449 | "triggerWhenComplete": true 450 | }, 451 | { 452 | "prefix": "< ?>$", 453 | "body": "\\diamond ", 454 | "mode": "maths", 455 | "description": "⋄", 456 | "triggerWhenComplete": true 457 | }, 458 | { 459 | "prefix": "\\bcase$", 460 | "body": "\\begin{cases} $$1 \\end{cases} ", 461 | "mode": "maths", 462 | "description": "cases", 463 | "triggerWhenComplete": true 464 | }, 465 | { 466 | "prefix": "(^|[^\\\\])\\bst$", 467 | "body": "$1\\text{s.t.} ", 468 | "mode": "maths", 469 | "description": "such that", 470 | "triggerWhenComplete": true 471 | }, 472 | { 473 | "prefix": "\\+ ?-$", 474 | "body": "\\pm ", 475 | "mode": "maths", 476 | "description": "±", 477 | "priority": 1, 478 | "triggerWhenComplete": true 479 | }, 480 | { 481 | "prefix": "- ?\\+$", 482 | "body": "\\mp ", 483 | "mode": "maths", 484 | "description": "∓", 485 | "priority": 1, 486 | "triggerWhenComplete": true 487 | }, 488 | { 489 | "prefix": "(?:([A-Za-z0-9]|\\\\\\w{,7})|\\(([^\\)]+)\\))C(?:([A-Za-z0-9]|\\\\\\w{,7})|\\(([^\\)]+)\\))$", 490 | "body": "\\binom{$1$2}{$3$4}", 491 | "mode": "maths", 492 | "priority": 2, 493 | "description": "binomial", 494 | "triggerWhenComplete": true 495 | } 496 | ] 497 | -------------------------------------------------------------------------------- /scripts/countword-linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | docker run -i --rm -w "$(pwd)" -v "$(pwd):$(pwd)" $LATEXWORKSHOP_DOCKER_LATEX texcount "$@" 3 | -------------------------------------------------------------------------------- /scripts/countword-win.bat: -------------------------------------------------------------------------------- 1 | docker run -i --rm -w /data -v "%cd%:/data" %LATEXWORKSHOP_DOCKER_LATEX% texcount %* 2 | -------------------------------------------------------------------------------- /scripts/saveclipimg-linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # SOURCED FROM https://github.com/mushanshitiancai/vscode-paste-image/ 3 | 4 | # require xclip(see http://stackoverflow.com/questions/592620/check-if-a-program-exists-from-a-bash-script/677212#677212) 5 | command -v xclip >/dev/null 2>&1 || { echo >&1 "no xclip"; exit 1; } 6 | 7 | # write image in clipboard to file (see http://unix.stackexchange.com/questions/145131/copy-image-from-clipboard-to-file) 8 | if 9 | xclip -selection clipboard -target image/png -o >/dev/null 2>&1 10 | then 11 | xclip -selection clipboard -target image/png -o >$1 2>/dev/null 12 | echo $1 13 | else 14 | echo "no image" 15 | fi 16 | -------------------------------------------------------------------------------- /scripts/saveclipimg-mac.applescript: -------------------------------------------------------------------------------- 1 | -- SOURCED FROM https://github.com/mushanshitiancai/vscode-paste-image/ 2 | 3 | property fileTypes : {{«class PNGf», ".png"}} 4 | 5 | on run argv 6 | if argv is {} then 7 | return "" 8 | end if 9 | 10 | set imagePath to (item 1 of argv) 11 | set theType to getType() 12 | 13 | if theType is not missing value then 14 | try 15 | set myFile to (open for access imagePath with write permission) 16 | set eof myFile to 0 17 | write (the clipboard as (first item of theType)) to myFile 18 | close access myFile 19 | return (POSIX path of imagePath) 20 | on error 21 | try 22 | close access myFile 23 | end try 24 | return "" 25 | end try 26 | else 27 | return "no image" 28 | end if 29 | end run 30 | 31 | on getType() 32 | repeat with aType in fileTypes 33 | repeat with theInfo in (clipboard info) 34 | if (first item of theInfo) is equal to (first item of aType) then return aType 35 | end repeat 36 | end repeat 37 | return missing value 38 | end getType 39 | -------------------------------------------------------------------------------- /scripts/saveclipimg-pc.ps1: -------------------------------------------------------------------------------- 1 | # SOURCED FROM https://github.com/mushanshitiancai/vscode-paste-image/ 2 | 3 | param($imagePath) 4 | 5 | # Adapted from https://github.com/octan3/img-clipboard-dump/blob/master/dump-clipboard-png.ps1 6 | 7 | Add-Type -Assembly PresentationCore 8 | 9 | if ($PSVersionTable.PSVersion.Major -ge 5 -and $PSVersionTable.PSVersion.Major -ge 1) 10 | { 11 | $file = Get-Clipboard -Format FileDropList 12 | if ($file -ne $null) { 13 | $img = new-object System.Drawing.Bitmap($file[0].Fullname) 14 | } else { 15 | $img = Get-Clipboard -Format Image 16 | } 17 | 18 | if ($img -eq $null) { 19 | "no image" 20 | Exit 1 21 | } 22 | 23 | if (-not $imagePath) { 24 | "no image" 25 | Exit 1 26 | } 27 | 28 | $img.save($imagePath) 29 | } 30 | else 31 | { 32 | $img = [Windows.Clipboard]::GetImage() 33 | if ($img -eq $null) { 34 | "no image" 35 | Exit 1 36 | } 37 | 38 | if (-not $imagePath) { 39 | "no image" 40 | Exit 1 41 | } 42 | 43 | $fcb = new-object Windows.Media.Imaging.FormatConvertedBitmap($img, [Windows.Media.PixelFormats]::Rgb24, $null, 0) 44 | $stream = [IO.File]::Open($imagePath, "OpenOrCreate") 45 | $encoder = New-Object Windows.Media.Imaging.PngBitmapEncoder 46 | $encoder.Frames.Add([Windows.Media.Imaging.BitmapFrame]::Create($fcb)) | out-null 47 | $encoder.Save($stream) | out-null 48 | $stream.Dispose() | out-null 49 | } 50 | 51 | $imagePath 52 | -------------------------------------------------------------------------------- /src/components/completionWatcher.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { Extension } from '../main' 3 | import { TypeFinder } from './typeFinder' 4 | import { exec } from 'child_process' 5 | import * as path from 'path' 6 | import { existsSync, readFileSync } from 'fs' 7 | import { removeSync } from 'fs-extra' 8 | 9 | interface ISnippet { 10 | prefix: RegExp 11 | body: string 12 | description?: string 13 | priority?: number 14 | triggerWhenComplete?: boolean 15 | mode?: 'maths' | 'text' | 'any' 16 | noPlaceholders?: boolean 17 | } 18 | 19 | interface ISnippetConfig { 20 | prefix: string 21 | body: string 22 | description?: string 23 | priority?: number 24 | triggerWhenComplete?: boolean 25 | mode?: 'maths' | 'text' | 'any' 26 | noPlaceholders?: boolean 27 | } 28 | 29 | const DEBUG_CONSOLE_LOG = false 30 | 31 | /* eslint-disable */ 32 | let debuglog: (icon: string, start: number, action: string) => void; 33 | if (DEBUG_CONSOLE_LOG) { 34 | debuglog = function (icon, start, action) { 35 | console.log(`${icon} Watcher took ${+new Date() - start}ms ${action}`); 36 | }; 37 | } else { 38 | debuglog = (_i, _s, _a) => {}; 39 | } 40 | /* eslint-enable */ 41 | export class CompletionWatcher { 42 | extension: Extension 43 | typeFinder: TypeFinder 44 | private lastChanges: vscode.TextDocumentChangeEvent | undefined 45 | private lastKnownType: 46 | | { 47 | position: vscode.Position 48 | mode: 'maths' | 'text' 49 | } 50 | | undefined 51 | currentlyExecutingChange = false 52 | private enabled: boolean 53 | private configAge: number 54 | private MAX_CONFIG_AGE = 5000 55 | snippets: ISnippet[] = [] 56 | snippetsConfig: ISnippetConfig[] = [] 57 | activeSnippets: vscode.CompletionItem[] = [] 58 | 59 | constructor(extension: Extension) { 60 | this.extension = extension 61 | this.typeFinder = new TypeFinder() 62 | this.enabled = vscode.workspace.getConfiguration('latex-utilities').get('liveReformat.enabled') as boolean 63 | this.configAge = +new Date() 64 | vscode.workspace.onDidChangeTextDocument(this.watcher, this) 65 | 66 | if (existsSync(this.getUserSnippetsFile())) { 67 | this.extension.logger.addLogMessage('User Snippets File Found, migrating to new config') 68 | const snippets = JSON.parse(readFileSync(this.getUserSnippetsFile(), { encoding: 'utf8' })) 69 | this.extension.logger.addLogMessage('User Snippets File Read') 70 | this.extension.logger.addLogMessage(JSON.stringify(snippets)) 71 | // update config 72 | vscode.workspace.getConfiguration('latex-utilities').update('liveReformat.snippets', snippets, true) 73 | // remove user snippets file 74 | removeSync(this.getUserSnippetsFile()) 75 | this.extension.logger.addLogMessage('User Snippets File Migrated') 76 | } 77 | this.loadSnippets() 78 | extension.logger.addLogMessage('Completion Watcher Initialised') 79 | } 80 | 81 | private processSnippets() { 82 | for (let i = 0; i < this.snippets.length; i++) { 83 | const snippet = this.snippets[i] 84 | if (!/\$\$(?:\d|{\d)/.test(snippet.body) && snippet.noPlaceholders === undefined) { 85 | snippet.noPlaceholders = true 86 | if (snippet.priority === undefined) { 87 | snippet.priority = -0.1 88 | } 89 | } 90 | if (snippet.priority === undefined) { 91 | snippet.priority = 0 92 | } 93 | if (snippet.triggerWhenComplete === undefined) { 94 | snippet.triggerWhenComplete = false 95 | } 96 | if (snippet.mode === undefined) { 97 | snippet.mode = 'any' 98 | } else if (!/^maths|text|any$/.test(snippet.mode)) { 99 | this.extension.logger.addLogMessage( 100 | `Invalid mode (${snippet.mode}) for live snippet "${snippet.description}"` 101 | ) 102 | } 103 | } 104 | this.snippets.sort((a, b) => { 105 | return (b.priority === undefined ? 0 : b.priority) - (a.priority === undefined ? 0 : a.priority) 106 | }) 107 | } 108 | 109 | public async watcher(e: vscode.TextDocumentChangeEvent) { 110 | if (+new Date() - this.configAge > this.MAX_CONFIG_AGE) { 111 | this.enabled = vscode.workspace.getConfiguration('latex-utilities').get('liveReformat.enabled') as boolean 112 | this.loadSnippets() 113 | this.configAge = +new Date() 114 | } 115 | const languages = vscode.workspace.getConfiguration('latex-utilities').get('liveReformat.languages') as string[] 116 | if ( 117 | !languages.includes(e.document.languageId) || 118 | e.contentChanges.length === 0 || 119 | this.currentlyExecutingChange || 120 | this.sameChanges(e) || 121 | !this.enabled || 122 | !vscode.window.activeTextEditor 123 | ) { 124 | return 125 | } 126 | 127 | this.lastChanges = e 128 | this.activeSnippets = [] 129 | 130 | const start = +new Date() 131 | let columnOffset = 0 132 | for (const change of e.contentChanges) { 133 | const type = this.typeFinder.getTypeAtPosition(e.document, change.range.start, this.lastKnownType) 134 | this.lastKnownType = { position: change.range.start, mode: type } 135 | if (change.range.isSingleLine) { 136 | let line = e.document.lineAt(change.range.start.line) 137 | for (let i = 0; i < this.snippets.length; i++) { 138 | if (this.snippets[i].mode === 'any' || this.snippets[i].mode === type) { 139 | const newColumnOffset = await this.execSnippet(this.snippets[i], line, change, columnOffset) 140 | if (newColumnOffset === 'break') { 141 | break 142 | } else if (newColumnOffset !== undefined) { 143 | columnOffset += newColumnOffset 144 | line = e.document.lineAt(change.range.start.line) 145 | } 146 | } 147 | } 148 | } 149 | } 150 | this.extension.telemetryReporter.sendTelemetryEvent('liveSnippetTimings', { 151 | timeToCheck: (start - +new Date()).toString() 152 | }) 153 | debuglog('🔵', start, 'to check for snippets') 154 | } 155 | 156 | private sameChanges(changes: vscode.TextDocumentChangeEvent) { 157 | if (!this.lastChanges) { 158 | return false 159 | } else if (this.lastChanges.contentChanges.length !== changes.contentChanges.length) { 160 | return false 161 | } else { 162 | const changeSame = this.lastChanges.contentChanges.every((value, index) => { 163 | const newChange = changes.contentChanges[index] 164 | if (value.text !== newChange.text || !value.range.isEqual(newChange.range)) { 165 | return false 166 | } 167 | 168 | return true 169 | }) 170 | if (!changeSame) { 171 | return false 172 | } 173 | } 174 | 175 | return true 176 | } 177 | 178 | private async execSnippet( 179 | snippet: ISnippet, 180 | line: vscode.TextLine, 181 | change: vscode.TextDocumentContentChangeEvent, 182 | columnOffset: number 183 | ): Promise { 184 | return new Promise((resolve, reject) => { 185 | const match = snippet.prefix.exec( 186 | line.text.substr(0, change.range.start.character + change.text.length + columnOffset) 187 | ) 188 | if (match && vscode.window.activeTextEditor) { 189 | let matchRange: vscode.Range 190 | let replacement: string 191 | if (snippet.body === 'SPECIAL_ACTION_BREAK') { 192 | resolve('break') 193 | return 194 | } else if (snippet.body === 'SPECIAL_ACTION_FRACTION') { 195 | [matchRange, replacement] = this.getFraction(match, line) 196 | } else { 197 | matchRange = new vscode.Range( 198 | new vscode.Position(line.lineNumber, match.index), 199 | new vscode.Position(line.lineNumber, match.index + match[0].length) 200 | ) 201 | if (snippet.body === 'SPECIAL_ACTION_SYMPY') { 202 | replacement = this.execSympy(match, line) 203 | } else { 204 | replacement = match[0].replace(snippet.prefix, snippet.body).replace(/\$\$/g, '$') 205 | } 206 | } 207 | if (snippet.triggerWhenComplete) { 208 | this.currentlyExecutingChange = true 209 | const changeStart = +new Date() 210 | if (snippet.noPlaceholders) { 211 | vscode.window.activeTextEditor 212 | .edit( 213 | editBuilder => { 214 | editBuilder.replace(matchRange, replacement) 215 | }, 216 | { undoStopBefore: true, undoStopAfter: true } 217 | ) 218 | .then(() => { 219 | const offset = replacement.length - match[0].length 220 | if (vscode.window.activeTextEditor && offset > 0) { 221 | vscode.window.activeTextEditor.selection = new vscode.Selection( 222 | vscode.window.activeTextEditor.selection.anchor.translate(0, offset), 223 | vscode.window.activeTextEditor.selection.anchor.translate(0, offset) 224 | ) 225 | } 226 | this.currentlyExecutingChange = false 227 | debuglog(' ▹', changeStart, 'to perform text replacement') 228 | resolve(offset) 229 | }) 230 | } else { 231 | vscode.window.activeTextEditor 232 | .edit( 233 | editBuilder => { 234 | editBuilder.delete(matchRange) 235 | }, 236 | { undoStopBefore: true, undoStopAfter: false } 237 | ) 238 | .then( 239 | () => { 240 | if (!vscode.window.activeTextEditor) { 241 | return 242 | } 243 | vscode.window.activeTextEditor 244 | .insertSnippet(new vscode.SnippetString(replacement), undefined, { 245 | undoStopBefore: true, 246 | undoStopAfter: true 247 | }) 248 | .then( 249 | () => { 250 | this.currentlyExecutingChange = false 251 | debuglog(' ▹', changeStart, 'to insert snippet') 252 | resolve(replacement.length - match[0].length) 253 | }, 254 | (reason: unknown) => { 255 | this.currentlyExecutingChange = false 256 | reject(reason) 257 | } 258 | ) 259 | }, 260 | (reason: unknown) => { 261 | this.currentlyExecutingChange = false 262 | reject(reason) 263 | } 264 | ) 265 | } 266 | } else { 267 | this.activeSnippets.push({ 268 | label: replacement, 269 | filterText: match[0], 270 | sortText: match[0], 271 | range: matchRange, 272 | detail: 'live snippet', 273 | kind: vscode.CompletionItemKind.Reference 274 | }) 275 | } 276 | } else { 277 | resolve(undefined) 278 | } 279 | }) 280 | } 281 | 282 | public provide(): vscode.CompletionItem[] { 283 | return this.activeSnippets 284 | } 285 | 286 | public editSnippetsFile() { 287 | vscode.commands.executeCommand('workbench.action.openSettingsJson', { 288 | revealSetting: { 289 | key: 'latex-utilities.liveReformat.snippets', 290 | edit: true, 291 | } 292 | }) 293 | } 294 | 295 | public loadSnippets() { 296 | // if this.snippetsConfig is same with getConfiguration, skip 297 | if (JSON.stringify(this.snippetsConfig) === JSON.stringify(vscode.workspace.getConfiguration('latex-utilities').get('liveReformat.snippets'))) { 298 | return 299 | } 300 | this.snippetsConfig = vscode.workspace.getConfiguration('latex-utilities').get('liveReformat.snippets') as ISnippetConfig[] 301 | try { 302 | this.snippets = [] 303 | for (let i = 0; i < this.snippetsConfig.length; i++) { 304 | // snippets[i].prefix = new RegExp(snippets[i].prefix); 305 | this.snippets.push({ 306 | ...this.snippetsConfig[i], 307 | prefix: new RegExp(this.snippetsConfig[i].prefix) 308 | }) 309 | } 310 | this.processSnippets() 311 | } catch (error) { 312 | this.extension.logger.logError(error) 313 | this.extension.logger.showErrorMessage('Couldn\'t load snippets file. Is it a valid JSON?') 314 | } 315 | } 316 | 317 | private getUserSnippetsFile() { 318 | const codeFolder = vscode.version.indexOf('insider') >= 0 ? 'Code - Insiders' : 'Code' 319 | const templateName = 'latexUtilsLiveSnippets.json' 320 | 321 | if (process.platform === 'win32' && process.env.APPDATA) { 322 | return path.join(process.env.APPDATA, codeFolder, 'User', templateName) 323 | } else if (process.platform === 'darwin' && process.env.HOME) { 324 | return path.join(process.env.HOME, 'Library', 'Application Support', codeFolder, 'User', templateName) 325 | } else if (process.platform === 'linux' && process.env.HOME) { 326 | let conf = path.join(process.env.HOME, '.config', codeFolder, 'User', templateName) 327 | if (existsSync(conf)) { 328 | return conf 329 | } else { 330 | conf = path.join(process.env.HOME, '.config', 'Code - OSS', 'User', templateName) 331 | return conf 332 | } 333 | } else { 334 | return '' 335 | } 336 | } 337 | 338 | private getFraction(match: RegExpExecArray, line: vscode.TextLine): [vscode.Range, string] { 339 | type TclosingBracket = ')' | ']' | '}' 340 | type TopeningBracket = '(' | '[' | '{' 341 | const closingBracket = match[1] as TclosingBracket 342 | // eslint-disable-next-line @typescript-eslint/naming-convention 343 | const openingBracket = { ')': '(', ']': '[', '}': '{' }[closingBracket] as TopeningBracket 344 | let depth = 0 345 | for (let i = match.index; i >= 0; i--) { 346 | if (line.text[i] === closingBracket) { 347 | depth-- 348 | } else if (line.text[i] === openingBracket) { 349 | depth++ 350 | } 351 | if (depth === 0) { 352 | // if command keep going till the \ 353 | const commandMatch = /.*(\\\w+)$/.exec(line.text.substr(0, i)) 354 | if (closingBracket === '}') { 355 | if (commandMatch !== null) { 356 | i -= commandMatch[1].length 357 | } 358 | } 359 | const matchRange = new vscode.Range( 360 | new vscode.Position(line.lineNumber, i), 361 | new vscode.Position(line.lineNumber, match.index + match[0].length) 362 | ) 363 | const replacement = `\\frac{${commandMatch ? '\\' : ''}${line.text.substring(i + 1, match.index)}}{$1} ` 364 | return [matchRange, replacement] 365 | } 366 | } 367 | return [ 368 | new vscode.Range( 369 | new vscode.Position(line.lineNumber, match.index + match[0].length), 370 | new vscode.Position(line.lineNumber, match.index + match[0].length) 371 | ), 372 | '' 373 | ] 374 | } 375 | 376 | private execSympy(match: RegExpExecArray, line: vscode.TextLine) { 377 | const replacement = 'SYMPY_CALCULATING' 378 | const command = match[1] 379 | .replace(/\\(\w+) ?/g, '$1') 380 | .replace(/\^/, '**') 381 | .replace('{', '(') 382 | .replace('}', ')') 383 | exec( 384 | `python3 -c "from sympy import * 385 | import re 386 | a, b, c, x, y, z, t = symbols('a b c x y z t') 387 | k, m, n = symbols('k m n', integer=True) 388 | f, g, h = symbols('f g h', cls=Function) 389 | init_printing() 390 | print(eval('''latex(${command})'''), end='')"`, 391 | { encoding: 'utf8' }, 392 | (_error, stdout, stderr) => { 393 | if (!vscode.window.activeTextEditor) { 394 | return 395 | } else if (stderr) { 396 | stdout = 'SYMPY_ERROR' 397 | setTimeout(() => { 398 | this.extension.logger.addLogMessage(`error executing sympy command: ${command}`) 399 | if (!vscode.window.activeTextEditor) { 400 | return 401 | } 402 | 403 | vscode.window.activeTextEditor.edit(editBuilder => { 404 | editBuilder.delete( 405 | new vscode.Range( 406 | new vscode.Position(line.lineNumber, match.index), 407 | new vscode.Position(line.lineNumber, match.index + stdout.length) 408 | ) 409 | ) 410 | }) 411 | }, 400) 412 | } 413 | vscode.window.activeTextEditor.edit(editBuilder => { 414 | editBuilder.replace( 415 | new vscode.Range( 416 | new vscode.Position(line.lineNumber, match.index), 417 | new vscode.Position(line.lineNumber, match.index + replacement.length) 418 | ), 419 | stdout 420 | ) 421 | }) 422 | } 423 | ) 424 | return replacement 425 | } 426 | } 427 | 428 | export class Completer implements vscode.CompletionItemProvider { 429 | extension: Extension 430 | 431 | constructor(extension: Extension) { 432 | this.extension = extension 433 | } 434 | 435 | provideCompletionItems( 436 | /* eslint-disable @typescript-eslint/no-unused-vars */ 437 | _document: vscode.TextDocument, 438 | _position: vscode.Position, 439 | _token: vscode.CancellationToken, 440 | _context: vscode.CompletionContext 441 | /* eslint-enable @typescript-eslint/no-unused-vars */ 442 | ): vscode.ProviderResult { 443 | return this.extension.completionWatcher.activeSnippets 444 | } 445 | } 446 | -------------------------------------------------------------------------------- /src/components/logger.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | 3 | import { Extension } from '../main' 4 | 5 | export class Logger { 6 | extension: Extension 7 | logPanel: vscode.OutputChannel 8 | 9 | constructor(extension: Extension) { 10 | this.extension = extension 11 | this.logPanel = vscode.window.createOutputChannel('LaTeX Utilities') 12 | this.addLogMessage('Initializing LaTeX Utilities.') 13 | } 14 | 15 | addLogMessage(message: string) { 16 | this.logPanel.append(`[${new Date().toLocaleTimeString('en-US', { hour12: false })}] ${message}\n`) 17 | } 18 | 19 | showErrorMessage(message: string, ...args: any): Thenable | undefined { 20 | const configuration = vscode.workspace.getConfiguration('latex-utilities') 21 | if (configuration.get('message.error.show')) { 22 | return vscode.window.showErrorMessage(message, ...args) 23 | } else { 24 | return undefined 25 | } 26 | } 27 | 28 | showLog() { 29 | this.logPanel.show() 30 | } 31 | 32 | logError(e: Error) { 33 | this.addLogMessage(e.message) 34 | if (e.stack) { 35 | this.addLogMessage(e.stack) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/components/paster.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import * as path from 'path' 3 | import * as fs from 'fs/promises' 4 | import { constants as fsconstants } from 'fs' 5 | import * as fse from 'fs-extra' 6 | import { ChildProcessWithoutNullStreams, spawn } from 'child_process' 7 | import csv from 'csv-parser' 8 | import { Readable } from 'stream' 9 | 10 | import { Extension } from '../main' 11 | 12 | export class Paster { 13 | extension: Extension 14 | 15 | constructor(extension: Extension) { 16 | this.extension = extension 17 | } 18 | 19 | public async paste() { 20 | this.extension.logger.addLogMessage('Performing formatted paste') 21 | 22 | // get current edit file path 23 | const editor = vscode.window.activeTextEditor 24 | if (!editor) { 25 | return 26 | } 27 | 28 | const fileUri = editor.document.uri 29 | if (!fileUri) { 30 | return 31 | } 32 | 33 | const clipboardContents = await vscode.env.clipboard.readText() 34 | 35 | // if empty try pasting an image from clipboard 36 | // also try pasting image first on linux 37 | if (clipboardContents === '' || process.platform === 'linux') { 38 | if (fileUri.scheme === 'untitled') { 39 | vscode.window.showInformationMessage('You need to the save the current editor before pasting an image') 40 | 41 | return 42 | } 43 | if (await this.pasteImage(editor, fileUri.fsPath)){ 44 | this.extension.logger.addLogMessage('paste image success. returning') 45 | return 46 | } 47 | this.extension.logger.addLogMessage('paste image finished and failed.') 48 | } 49 | 50 | if (clipboardContents.split('\n').length === 1) { 51 | let filePath: string 52 | let basePath: string 53 | if (fileUri.scheme === 'untitled') { 54 | filePath = clipboardContents 55 | basePath = '' 56 | } else { 57 | filePath = path.resolve(fileUri.fsPath, clipboardContents) 58 | basePath = fileUri.fsPath 59 | } 60 | try { 61 | await fs.access(filePath, fsconstants.R_OK) 62 | if (await this.pasteFile(editor, basePath, clipboardContents)) { 63 | this.extension.logger.addLogMessage('paste file success. returning') 64 | return 65 | } 66 | } catch (error) { 67 | // pass 68 | } 69 | } 70 | // if not pasting file 71 | try { 72 | await this.pasteTable(editor, clipboardContents) 73 | } catch (error) { 74 | this.pasteNormal( 75 | editor, 76 | this.reformatText( 77 | clipboardContents, 78 | true, 79 | vscode.workspace.getConfiguration('latex-utilities.formattedPaste').get('maxLineLength') as number, 80 | editor 81 | ) 82 | ) 83 | this.extension.telemetryReporter.sendTelemetryEvent('formattedPaste', { type: 'text' }) 84 | } 85 | } 86 | 87 | public pasteNormal(editor: vscode.TextEditor, content: string) { 88 | editor.edit(edit => { 89 | const current = editor.selection 90 | 91 | if (current.isEmpty) { 92 | edit.insert(current.start, content) 93 | } else { 94 | edit.replace(current, content) 95 | } 96 | }) 97 | } 98 | 99 | public async pasteFile(editor: vscode.TextEditor, baseFile: string, file: string): Promise { 100 | this.extension.logger.addLogMessage('Pasting: file') 101 | const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.eps', '.pdf'] 102 | const TABLE_FORMATS = ['.csv'] 103 | const extension = path.extname(file) 104 | 105 | if (IMAGE_EXTENSIONS.indexOf(extension) !== -1) { 106 | this.extension.logger.addLogMessage('File is an image.') 107 | return this.pasteImage(editor, baseFile, file) 108 | } else if (TABLE_FORMATS.indexOf(extension) !== -1) { 109 | if (extension === '.csv') { 110 | const fileContent = await fs.readFile(path.resolve(baseFile, file)) 111 | await this.pasteTable(editor, fileContent.toString()) 112 | return true 113 | } 114 | } 115 | return false 116 | } 117 | 118 | public async pasteTable(editor: vscode.TextEditor, content: string, delimiter?: string) { 119 | this.extension.logger.addLogMessage('Pasting: Table') 120 | const configuration = vscode.workspace.getConfiguration('latex-utilities.formattedPaste') 121 | 122 | const columnDelimiter: string = delimiter || configuration.customTableDelimiter 123 | const columnType: string = configuration.tableColumnType 124 | const booktabs: boolean = configuration.tableBooktabsStyle 125 | const headerRows: number = configuration.tableHeaderRows 126 | 127 | const trimUnwantedWhitespace = (s: string) => 128 | s 129 | .replace(/\r\n/g, '\n') 130 | .replace(/^[^\S\t]+|[^\S\t]+$/gm, '') 131 | .replace(/^[\uFEFF\xA0]+|[\uFEFF\xA0]+$/gm, '') 132 | content = trimUnwantedWhitespace(content) 133 | 134 | const TEST_DELIMITERS = new Set([columnDelimiter, '\t', ',', '|']) 135 | const tables: string[][][] = [] 136 | 137 | for (const testDelimiter of TEST_DELIMITERS) { 138 | try { 139 | const table = await this.processTable(content, testDelimiter) 140 | tables.push(table) 141 | this.extension.logger.addLogMessage(`Successfully found ${testDelimiter} delimited table`) 142 | } catch (e) { 143 | this.extension.logger.addLogMessage(`Failed to find ${testDelimiter} delimited table`) 144 | this.extension.logger.addLogMessage(e) 145 | } 146 | } 147 | 148 | if (tables.length === 0) { 149 | this.extension.logger.addLogMessage('No table found') 150 | if (configuration.tableDelimiterPrompt) { 151 | const columnDelimiterNew = await vscode.window.showInputBox({ 152 | prompt: 'Please specify the table cell delimiter', 153 | value: columnDelimiter, 154 | placeHolder: columnDelimiter, 155 | validateInput: (text: string) => { 156 | return text === '' ? 'No delimiter specified!' : null 157 | } 158 | }) 159 | if (columnDelimiterNew === undefined) { 160 | throw new Error('no table cell delimiter set') 161 | } 162 | 163 | try { 164 | const table = await this.processTable(content, columnDelimiterNew) 165 | tables.push(table) 166 | this.extension.logger.addLogMessage(`Successfully found ${columnDelimiterNew} delimited table`) 167 | } catch (e) { 168 | vscode.window.showWarningMessage(e) 169 | throw Error('Unable to identify table') 170 | } 171 | } else { 172 | throw Error('Unable to identify table') 173 | } 174 | } 175 | 176 | // put the 'biggest' table first 177 | tables.sort((a, b) => a.length * a[0].length - b.length * b[0].length) 178 | const table = tables[0].map(row => row.map(cell => this.reformatText(cell.replace(/^\s+|\s+$/gm, ''), false))) 179 | 180 | const tabularRows = table.map(row => '\t' + row.join(' & ')) 181 | 182 | if (headerRows && tabularRows.length > headerRows) { 183 | const eol = editor.document.eol === vscode.EndOfLine.LF ? '\n' : '\r\n' 184 | const headSep = '\t' + (booktabs ? '\\midrule' : '\\hline') + eol 185 | tabularRows[headerRows] = headSep + tabularRows[headerRows] 186 | } 187 | let tabularContents = tabularRows.join(' \\\\\n') 188 | if (booktabs) { 189 | tabularContents = '\t\\toprule\n' + tabularContents + ' \\\\\n\t\\bottomrule' 190 | } 191 | const tabular = `\\begin{tabular}{${columnType.repeat(table[0].length)}}\n${tabularContents}\n\\end{tabular}` 192 | 193 | editor.edit(edit => { 194 | const current = editor.selection 195 | 196 | if (current.isEmpty) { 197 | edit.insert(current.start, tabular) 198 | } else { 199 | edit.replace(current, tabular) 200 | } 201 | }) 202 | 203 | this.extension.telemetryReporter.sendTelemetryEvent('formattedPaste', { type: 'table' }) 204 | } 205 | 206 | private processTable(content: string, delimiter = ','): Promise { 207 | const isConsistent = (rows: string[][]) => { 208 | return rows.reduce((accumulator, current, _index, array) => { 209 | if (current.length === array[0].length) { 210 | return accumulator 211 | } else { 212 | return false 213 | } 214 | }, true) 215 | } 216 | // if table is flanked by empty rows/columns, remove them 217 | const trimSides = (rows: string[][]): string[][] => { 218 | const emptyTop = rows[0].reduce((a, c) => c + a, '') === '' 219 | const emptyBottom = rows[rows.length - 1].reduce((a, c) => c + a, '') === '' 220 | const emptyLeft = rows.reduce((a, c) => a + c[0], '') === '' 221 | const emptyRight = rows.reduce((a, c) => a + c[c.length - 1], '') === '' 222 | if (!(emptyTop || emptyBottom || emptyLeft || emptyRight)) { 223 | return rows 224 | } else { 225 | if (emptyTop) { 226 | rows.shift() 227 | } 228 | if (emptyBottom) { 229 | rows.pop() 230 | } 231 | if (emptyLeft) { 232 | rows.forEach(row => row.shift()) 233 | } 234 | if (emptyRight) { 235 | rows.forEach(row => row.pop()) 236 | } 237 | return trimSides(rows) 238 | } 239 | } 240 | return new Promise((resolve, reject) => { 241 | let rows: string[][] = [] 242 | const contentStream = new Readable() 243 | // if markdown / org mode / ascii table we want to strip some rows 244 | if (delimiter === '|') { 245 | const removeRowsRegex = /^\s*[-+:| ]+\s*$/ 246 | const lines = content.split('\n').filter(l => !removeRowsRegex.test(l)) 247 | content = lines.join('\n') 248 | } 249 | contentStream.push(content) 250 | contentStream.push(null) 251 | contentStream 252 | .pipe(csv({ headers: false, separator: delimiter })) 253 | .on('data', (data: { [key: string]: string }) => rows.push(Object.values(data))) 254 | .on('end', () => { 255 | rows = trimSides(rows) 256 | // determine if all rows have same number of cells 257 | if (!isConsistent(rows)) { 258 | reject('Table is not consistent') 259 | } else if (rows.length === 1 || rows[0].length === 1) { 260 | reject('Doesn\'t look like a table') 261 | } 262 | 263 | resolve(rows) 264 | }) 265 | }) 266 | } 267 | 268 | public reformatText( 269 | text: string, 270 | removeBonusWhitespace = true, 271 | maxLineLength: number | null = null, 272 | editor?: vscode.TextEditor 273 | ) { 274 | function doRemoveBonusWhitespace(str: string) { 275 | str = str.replace(/\u200B/g, '') // get rid of zero-width spaces 276 | str = str.replace(/\n{2,}/g, '\uE000') // 'save' multi-newlines to private use character 277 | str = str.replace(/\s+/g, ' ') // replace all whitespace with normal space 278 | str = str.replace(/\uE000/g, '\n\n') // re-insert multi-newlines 279 | str = str.replace(/\uE001/g, '\n') // this has been used as 'saved' whitespace 280 | str = str.replace(/\uE002/g, '\t') // this has been used as 'saved' whitespace 281 | str = str.replace(/^\s+|\s+$/g, '') 282 | 283 | return str 284 | } 285 | function fitToLineLength(lineLength: number, str: string, splitChars = [' ', ',', '.', ':', ';', '?', '!']) { 286 | const lines = [] 287 | const indent = editor 288 | ? editor.document.lineAt(editor.selection.start.line).text.replace(/^(\s*).*/, '$1') 289 | : '' 290 | let lastNewlinePosition = editor ? -editor.selection.start.character : 0 291 | let lastSplitCharPosition = 0 292 | let i 293 | for (i = 0; i < str.length; i++) { 294 | if (str[i] === '\n') { 295 | lines.push( 296 | (lines.length > 0 ? indent : '') + 297 | str 298 | .slice(Math.max(0, lastNewlinePosition), i) 299 | .replace(/^[^\S\t]+/, '') 300 | .replace(/\s+$/, '') 301 | ) 302 | lastNewlinePosition = i 303 | } 304 | if (splitChars.indexOf(str[i]) !== -1) { 305 | lastSplitCharPosition = i + 1 306 | } 307 | if (i - lastNewlinePosition >= lineLength - indent.length) { 308 | lines.push( 309 | (lines.length > 0 ? indent : '') + 310 | str 311 | .slice(Math.max(0, lastNewlinePosition), lastSplitCharPosition) 312 | .replace(/^[^\S\t]+/, '') 313 | .replace(/\s+$/, '') 314 | ) 315 | lastNewlinePosition = lastSplitCharPosition 316 | i = lastSplitCharPosition 317 | } 318 | } 319 | if (lastNewlinePosition < i) { 320 | lines.push( 321 | (lines.length > 0 ? indent : '') + 322 | str 323 | .slice(Math.max(0, lastNewlinePosition), i) 324 | .replace(/^\s+/, '') 325 | .replace(/\s+$/, '') 326 | ) 327 | } 328 | console.log(lines.map(l => lineLength - l.length)) 329 | return lines.join('\n') 330 | } 331 | 332 | // join hyphenated lines 333 | text = text.replace(/(\w+)-\s?$\s?\n(\w+)/gm, '$1$2\n') 334 | 335 | /* eslint-disable @typescript-eslint/naming-convention */ 336 | const textReplacements: { [key: string]: string } = { 337 | // escape latex special characters 338 | '\\\\': '\\textbackslash ', 339 | '&': '\\&', 340 | '%': '\\%', 341 | '\\$': '\\$', 342 | '#': '\\#', 343 | _: '\\_', 344 | '\\^': '\\textasciicircum ', 345 | '{': '\\{', 346 | '}': '\\}', 347 | '~': '\\textasciitilde ', 348 | // dumb quotes 349 | '\\B"([^"]+)"\\B': '``$1\'\'', 350 | '\\B\'([^\']+)\'\\B': '`$1\'', 351 | // 'smart' quotes 352 | '“': '``', 353 | '”': '\'\'', 354 | '‘': '`', 355 | '’': '\'', 356 | // unicode symbols 357 | '—': '---', // em dash 358 | '–': '--', // en dash 359 | ' -- ': ' --- ', // en dash that should be em 360 | '−': '-', // minus sign 361 | '…': '\\ldots ', // elipses 362 | '‐': '-', // hyphen 363 | '™': '\\texttrademark ', // trade mark 364 | '®': '\\textregistered ', // registered trade mark 365 | '©': '\\textcopyright ', // copyright 366 | '¢': '\\cent ', // copyright 367 | '£': '\\pound ', // copyright 368 | // unicode math 369 | '×': '\\(\\times \\)', 370 | '÷': '\\(\\div \\)', 371 | '±': '\\(\\pm \\)', 372 | '→': '\\(\\to \\)', 373 | '(\\d*)° ?(C|F)?': '\\($1^\\circ $2\\)', 374 | '≤': '\\(\\leq \\)', 375 | '≥': '\\(\\geq \\)', 376 | // typographic approximations 377 | '\\.\\.\\.': '\\ldots ', 378 | '-{20,}': '\\hline', 379 | '-{2,3}>': '\\(\\longrightarrow \\)', 380 | '->': '\\(\\to \\)', 381 | '<-{2,3}': '\\(\\longleftarrow \\)', 382 | '<-': '\\(\\leftarrow \\)', 383 | // more latex stuff 384 | '\\b([A-Z]+)\\.\\s([A-Z])': '$1\\@. $2', // sentences that end in a capital letter. 385 | '\\b(etc|ie|i\\.e|eg|e\\.g)\\.\\s(\\w)': '$1.\\ $2', // phrases that should have inter-word spacing 386 | // some funky unicode symbols that come up here and there 387 | '\\s?•\\s?': '\uE001\uE002\\item ', 388 | '\\n?((?:\\s*\uE002\\\\item .*)+)': '\uE001\\begin{itemize}\uE001$1\uE001\\end{itemize}\uE001', 389 | '': '<', 390 | '': '-', 391 | '': '>' 392 | } 393 | /* eslint-enable @typescript-eslint/naming-convention */ 394 | 395 | const texText = /\\[A-Za-z]{3,15}/ 396 | 397 | if (!texText.test(text)) { 398 | for (const pattern in textReplacements) { 399 | text = text.replace(new RegExp(pattern, 'gm'), textReplacements[pattern]) 400 | } 401 | } 402 | 403 | if (removeBonusWhitespace) { 404 | text = doRemoveBonusWhitespace(text) 405 | } 406 | 407 | if (maxLineLength !== null) { 408 | text = fitToLineLength(maxLineLength, text) 409 | } 410 | 411 | return text 412 | } 413 | 414 | // Image pasting code below from https://github.com/mushanshitiancai/vscode-paste-image/ 415 | // Copyright 2016 mushanshitiancai 416 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 417 | // The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 418 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 419 | 420 | PATH_VARIABLE_GRAPHICS_PATH = /\$\{graphicsPath\}/g 421 | PATH_VARIABLE_CURRNET_FILE_DIR = /\$\{currentFileDir\}/g 422 | 423 | PATH_VARIABLE_IMAGE_FILE_PATH = /\$\{imageFilePath\}/g 424 | PATH_VARIABLE_IMAGE_FILE_PATH_WITHOUT_EXT = /\$\{imageFilePathWithoutExt\}/g 425 | PATH_VARIABLE_IMAGE_FILE_NAME = /\$\{imageFileName\}/g 426 | PATH_VARIABLE_IMAGE_FILE_NAME_WITHOUT_EXT = /\$\{imageFileNameWithoutExt\}/g 427 | 428 | pasteTemplate = '' 429 | basePathConfig = '${graphicsPath}' 430 | graphicsPathFallback = '${currentFileDir}' 431 | 432 | public async pasteImage(editor: vscode.TextEditor, baseFile: string, imgFile?: string): Promise { 433 | this.extension.logger.addLogMessage(`Pasting: Image. imgFile: ${imgFile}`) 434 | 435 | const folderPath = path.dirname(baseFile) 436 | const projectPath = vscode.workspace.workspaceFolders 437 | ? vscode.workspace.workspaceFolders[0].uri.fsPath 438 | : folderPath 439 | 440 | // get selection as image file name, need check 441 | const selection = editor.selection 442 | const selectText = editor.document.getText(selection) 443 | if (selectText && /\//.test(selectText)) { 444 | vscode.window.showInformationMessage('Your selection is not a valid file name!') 445 | 446 | return false 447 | } 448 | 449 | this.loadImageConfig(projectPath, baseFile) 450 | 451 | if (imgFile && !selectText) { 452 | const imagePath = this.renderImagePaste(path.dirname(baseFile), imgFile) 453 | 454 | if (!vscode.window.activeTextEditor) { 455 | return false 456 | } 457 | vscode.window.activeTextEditor.insertSnippet(new vscode.SnippetString(imagePath), editor.selection.start, { 458 | undoStopBefore: true, 459 | undoStopAfter: true 460 | }) 461 | 462 | return false 463 | } 464 | 465 | const imagePath = await this.getImagePath(baseFile, imgFile, selectText, this.basePathConfig) 466 | this.extension.logger.addLogMessage(`Image path: ${imagePath}`) 467 | if (!imagePath) { 468 | this.extension.logger.addLogMessage('Could not get image path.') 469 | return false 470 | } 471 | // does the file exist? 472 | try { 473 | await fs.access(imagePath, fsconstants.F_OK) 474 | const choice = await vscode.window 475 | .showInformationMessage( 476 | `File ${imagePath} exists. Would you want to replace?`, 477 | 'Replace', 478 | 'Cancel' 479 | ) 480 | if (choice !== 'Replace') { 481 | this.extension.logger.addLogMessage('User cancelled the image paste.') 482 | return false 483 | } 484 | } catch(e) { 485 | // pass, we save the image if it doesn't exists 486 | } 487 | this.saveAndPaste(editor, imagePath, imgFile) 488 | return true 489 | } 490 | 491 | public loadImageConfig(projectPath: string, filePath: string) { 492 | const config = vscode.workspace.getConfiguration('latex-utilities.formattedPaste.image') 493 | 494 | // load other config 495 | const pasteTemplate: string | string[] | undefined = config.get('template') 496 | if (pasteTemplate === undefined) { 497 | throw new Error('No config value found for latex-utilities.imagePaste.template') 498 | } 499 | if (typeof pasteTemplate === 'string') { 500 | this.pasteTemplate = pasteTemplate 501 | } else { 502 | // is multiline string represented by array 503 | this.pasteTemplate = pasteTemplate.join('\n') 504 | } 505 | 506 | this.graphicsPathFallback = this.replacePathVariables('${currentFileDir}', projectPath, filePath) 507 | this.basePathConfig = this.replacePathVariables('${graphicsPath}', projectPath, filePath) 508 | this.pasteTemplate = this.replacePathVariables(this.pasteTemplate, projectPath, filePath) 509 | } 510 | 511 | public async getImagePath( 512 | filePath: string, 513 | imagePathCurrent = '', 514 | selectText: string, 515 | folderPathFromConfig: string 516 | ) { 517 | const graphicsPath = this.basePathConfig 518 | try { 519 | await this.ensureImgDirExists(graphicsPath) 520 | } catch (e) { 521 | vscode.window.showErrorMessage(`Could not find image directory: ${e.message}`) 522 | return null 523 | } 524 | const imgPostfixNumber = 525 | Math.max( 526 | 0, 527 | ...(await fs.readdir(graphicsPath)) 528 | .map(imagePath => parseInt(imagePath.replace(/^image(\d+)\.\w+/, '$1'))) 529 | .filter(num => !isNaN(num)) 530 | ) + 1 531 | const imgExtension = path.extname(imagePathCurrent) ? path.extname(imagePathCurrent) : '.png' 532 | const imageFileName = selectText ? selectText + imgExtension : `image${imgPostfixNumber}` + imgExtension 533 | 534 | let result = await vscode.window.showInputBox({ 535 | prompt: 'Please specify the filename of the image.', 536 | value: imageFileName, 537 | valueSelection: [imageFileName.length - imageFileName.length, imageFileName.length - 4] 538 | }) 539 | if (result) { 540 | if (!result.endsWith(imgExtension)) { 541 | result += imgExtension 542 | } 543 | 544 | result = makeImagePath(result) 545 | return result 546 | } else { 547 | return null 548 | } 549 | 550 | function makeImagePath(fileName: string) { 551 | // image output path 552 | const folderPath = path.dirname(filePath) 553 | let imagePath = '' 554 | 555 | // generate image path 556 | if (path.isAbsolute(folderPathFromConfig)) { 557 | imagePath = path.join(folderPathFromConfig, fileName) 558 | } else { 559 | imagePath = path.join(folderPath, folderPathFromConfig, fileName) 560 | } 561 | 562 | return imagePath 563 | } 564 | } 565 | 566 | public async saveAndPaste(editor: vscode.TextEditor, imgPath: string, oldPath?: string) { 567 | this.extension.logger.addLogMessage(`save and paste. imagePath: ${imgPath}`) 568 | if (oldPath) { 569 | await fs.copyFile(oldPath, imgPath) 570 | const imageString = this.renderImagePaste(this.basePathConfig, imgPath) 571 | 572 | const current = editor.selection 573 | if (!current.isEmpty) { 574 | editor.edit( 575 | editBuilder => { 576 | editBuilder.delete(current) 577 | }, 578 | { undoStopBefore: true, undoStopAfter: false } 579 | ) 580 | } 581 | 582 | if (!vscode.window.activeTextEditor) { 583 | return 584 | } 585 | vscode.window.activeTextEditor.insertSnippet( 586 | new vscode.SnippetString(imageString), 587 | editor.selection.start, 588 | { 589 | undoStopBefore: true, 590 | undoStopAfter: true 591 | } 592 | ) 593 | } else { 594 | const imagePathReturnByScript = await this.saveClipboardImageToFileAndGetPath(imgPath) 595 | if (!imagePathReturnByScript) { 596 | return 597 | } 598 | if (imagePathReturnByScript === 'no image') { 599 | vscode.window.showInformationMessage('No image in clipboard') 600 | return 601 | } 602 | 603 | const imageString = this.renderImagePaste(this.basePathConfig, imgPath) 604 | 605 | const current = editor.selection 606 | if (!current.isEmpty) { 607 | editor.edit( 608 | editBuilder => { 609 | editBuilder.delete(current) 610 | }, 611 | { undoStopBefore: true, undoStopAfter: false } 612 | ) 613 | } 614 | 615 | if (!vscode.window.activeTextEditor) { 616 | return 617 | } 618 | vscode.window.activeTextEditor.insertSnippet( 619 | new vscode.SnippetString(imageString), 620 | editor.selection.start, 621 | { 622 | undoStopBefore: true, 623 | undoStopAfter: true 624 | } 625 | ) 626 | } 627 | this.extension.telemetryReporter.sendTelemetryEvent('formattedPaste', { type: 'image' }) 628 | } 629 | 630 | private async ensureImgDirExists(imagePath: string){ 631 | try { 632 | const stats = await fs.stat(imagePath) 633 | if (stats.isDirectory()) { 634 | return 635 | } else { 636 | throw new Error(`The image destination directory '${imagePath}' is a file.`) 637 | } 638 | } catch (error) { 639 | if (error.code === 'ENOENT') { 640 | this.extension.logger.addLogMessage(`Image directory ${imagePath} doesn't exist. Trying to create it...`) 641 | await fse.ensureDir(imagePath) 642 | } else { 643 | throw error 644 | } 645 | } 646 | } 647 | 648 | private wrapProcessAsPromise(process: ChildProcessWithoutNullStreams): Promise { 649 | return new Promise((resolve, reject) => { 650 | let data = '' 651 | process.stdout.on('data', (newData) => { 652 | data += newData 653 | }) 654 | // wslPath-disable-next-line @typescript-eslint/no-unused-vars 655 | process.on('exit', (_code, _signal) => { 656 | if (_code === 0) { 657 | resolve(data) 658 | } else { 659 | reject(new Error(`wslpath failed with code ${_code} and signal ${_signal}`)) 660 | } 661 | }) 662 | process.on('error', e => { 663 | reject(e) 664 | }) 665 | }) 666 | } 667 | 668 | // TODO: turn into async function, and raise errors internally 669 | private async saveClipboardImageToFileAndGetPath( 670 | imagePath: string 671 | ): Promise { 672 | if (!imagePath) { 673 | return null 674 | } 675 | try { 676 | const platform = process.platform 677 | if (vscode.env.remoteName === 'wsl') { 678 | // WSL 679 | let scriptPath = path.join(this.extension.extensionRoot, './scripts/saveclipimg-pc.ps1') 680 | // convert scriptPath to windows path 681 | const scriptPathPromise = this.wrapProcessAsPromise(spawn('wslpath', [ 682 | '-w', 683 | scriptPath 684 | ])) 685 | scriptPath = (await scriptPathPromise).toString().trim().replace('\\wsl.localhost', '\\wsl$') // see Powershell/powershell#17623 686 | this.extension.logger.addLogMessage(`saveClipimg-pc.ps1: ${scriptPath}`) 687 | 688 | const imagePathPromise = this.wrapProcessAsPromise(spawn('wslpath', [ 689 | '-w', 690 | imagePath 691 | ])) 692 | imagePath = (await imagePathPromise).toString().trim() 693 | this.extension.logger.addLogMessage(`imagePath: ${imagePath}`) 694 | 695 | const pastePromise = this.wrapProcessAsPromise(spawn('powershell.exe', [ 696 | '-noprofile', 697 | '-noninteractive', 698 | '-nologo', 699 | '-sta', 700 | '-executionpolicy', 701 | 'unrestricted', 702 | '-windowstyle', 703 | 'hidden', 704 | '-file', 705 | scriptPath, 706 | imagePath 707 | ])) 708 | const data = (await pastePromise).toString().trim() 709 | return data 710 | } else if (platform === 'win32') { 711 | // Windows 712 | const scriptPath = path.join(this.extension.extensionRoot, './scripts/saveclipimg-pc.ps1') 713 | 714 | let command = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' 715 | try { 716 | fs.access(command, fsconstants.X_OK) 717 | } catch (e) { 718 | // powershell.exe doesn't exist; 719 | command = 'powershell' 720 | } 721 | const pastePromise = this.wrapProcessAsPromise(spawn(command, [ 722 | '-noprofile', 723 | '-noninteractive', 724 | '-nologo', 725 | '-sta', 726 | '-executionpolicy', 727 | 'unrestricted', 728 | '-windowstyle', 729 | 'hidden', 730 | '-file', 731 | scriptPath, 732 | imagePath 733 | ])) 734 | const data = (await pastePromise).toString().trim() 735 | return data 736 | } else if (platform === 'darwin') { 737 | // Mac 738 | const scriptPath = path.join(this.extension.extensionRoot, './scripts/saveclipimg-mac.applescript') 739 | 740 | const pastePromise = this.wrapProcessAsPromise(spawn('osascript', [scriptPath, imagePath])) 741 | const data = (await pastePromise).toString().trim() 742 | return data 743 | } else { 744 | // Linux 745 | 746 | const scriptPath = path.join(this.extension.extensionRoot, './scripts/saveclipimg-linux.sh') 747 | 748 | const ascript = spawn('sh', [scriptPath, imagePath]) 749 | const data = (await this.wrapProcessAsPromise(ascript)).toString().trim() 750 | 751 | if (data === 'no xclip') { 752 | vscode.window.showErrorMessage('You need to install xclip command first.') 753 | return null 754 | } 755 | return data 756 | } 757 | } catch (e) { 758 | console.log(e) 759 | vscode.window.showErrorMessage(`Error occured while trying to paste image. Name: ${e.name}, Message: ${e.message}`) 760 | return null 761 | } 762 | } 763 | 764 | public renderImagePaste(basePath: string, imageFilePath: string): string { 765 | if (basePath) { 766 | imageFilePath = path.relative(basePath, imageFilePath) 767 | if (process.platform === 'win32') { 768 | imageFilePath = imageFilePath.replace(/\\/g, '/') 769 | } 770 | } 771 | 772 | const ext = path.extname(imageFilePath) 773 | const imageFilePathWithoutExt = imageFilePath.replace(/\.\w+$/, '') 774 | const fileName = path.basename(imageFilePath) 775 | const fileNameWithoutExt = path.basename(imageFilePath, ext) 776 | 777 | let result = this.pasteTemplate 778 | 779 | result = result.replace(this.PATH_VARIABLE_IMAGE_FILE_PATH, imageFilePath) 780 | result = result.replace(this.PATH_VARIABLE_IMAGE_FILE_PATH_WITHOUT_EXT, imageFilePathWithoutExt) 781 | result = result.replace(this.PATH_VARIABLE_IMAGE_FILE_NAME, fileName) 782 | result = result.replace(this.PATH_VARIABLE_IMAGE_FILE_NAME_WITHOUT_EXT, fileNameWithoutExt) 783 | 784 | return result 785 | } 786 | 787 | public replacePathVariables( 788 | pathStr: string, 789 | _projectRoot: string, 790 | curFilePath: string, 791 | postFunction: (str: string) => string = x => x 792 | ): string { 793 | const currentFileDir = path.dirname(curFilePath) 794 | const text = vscode.window.activeTextEditor?.document.getText() 795 | if (!text) { 796 | return pathStr 797 | } 798 | let graphicsPath: string | string[] = this.extension.manager.getGraphicsPath(text) 799 | graphicsPath = graphicsPath.length !== 0 ? graphicsPath[0] : this.graphicsPathFallback 800 | graphicsPath = path.resolve(currentFileDir, graphicsPath) 801 | const override = vscode.workspace.getConfiguration('latex-utilities.formattedPaste').get('imagePathOverride') as string 802 | if (override.length !== 0) { 803 | graphicsPath = override 804 | } 805 | 806 | pathStr = pathStr.replace(this.PATH_VARIABLE_GRAPHICS_PATH, postFunction(graphicsPath)) 807 | pathStr = pathStr.replace(this.PATH_VARIABLE_CURRNET_FILE_DIR, postFunction(currentFileDir)) 808 | 809 | return pathStr 810 | } 811 | } 812 | -------------------------------------------------------------------------------- /src/components/typeFinder.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { stripComments } from '../utils' 3 | 4 | interface IEnvInfo { 5 | [token: string]: { 6 | mode: 'maths' | 'text' 7 | type: 'start' | 'end' 8 | pair: string | null 9 | } 10 | } 11 | 12 | const DEBUG_CONSOLE_LOG = false 13 | 14 | let debuglog: (start: number, lines: number | string, mode: 'text' | 'maths', reason: string) => void 15 | if (DEBUG_CONSOLE_LOG) { 16 | debuglog = function (start, lines, mode, reason) { 17 | console.log( 18 | `⚪ TypeFinder took ${+new Date() - start}ms and ${lines} lines to determine: ${ 19 | mode === 'text' ? '𝘁𝗲𝘅𝘁' : '𝗺𝗮𝘁𝗵𝘀' 20 | } ${reason}` 21 | ) 22 | } 23 | } else { 24 | // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars 25 | debuglog = (_s, _l, _m, _r) => {} 26 | } 27 | 28 | export class TypeFinder { 29 | /* eslint-disable @typescript-eslint/naming-convention */ 30 | private envs: IEnvInfo = { 31 | '\\(': { 32 | mode: 'maths', 33 | type: 'start', 34 | pair: '\\)' 35 | }, 36 | '\\[': { 37 | mode: 'maths', 38 | type: 'start', 39 | pair: '\\]' 40 | }, 41 | '\\begin{equation}': { 42 | mode: 'maths', 43 | type: 'start', 44 | pair: '\\end{equation}' 45 | }, 46 | '\\begin{displaymath}': { 47 | mode: 'maths', 48 | type: 'start', 49 | pair: '\\end{displaymath}' 50 | }, 51 | '\\begin{align}': { 52 | mode: 'maths', 53 | type: 'start', 54 | pair: '\\end{align}' 55 | }, 56 | '\\begin{gather}': { 57 | mode: 'maths', 58 | type: 'start', 59 | pair: '\\end{gather}' 60 | }, 61 | '\\begin{flalign}': { 62 | mode: 'maths', 63 | type: 'start', 64 | pair: '\\end{flalign}' 65 | }, 66 | '\\begin{multline}': { 67 | mode: 'maths', 68 | type: 'start', 69 | pair: '\\end{multline}' 70 | }, 71 | '\\begin{alignat}': { 72 | mode: 'maths', 73 | type: 'start', 74 | pair: '\\end{alignat}' 75 | }, 76 | '\\begin{split}': { 77 | mode: 'maths', 78 | type: 'start', 79 | pair: '\\end{split}' 80 | }, 81 | '\\text': { 82 | mode: 'text', 83 | type: 'start', 84 | pair: null 85 | }, 86 | '\\begin{document}': { 87 | mode: 'text', 88 | type: 'start', 89 | pair: null 90 | }, 91 | '\\chapter': { 92 | mode: 'text', 93 | type: 'start', 94 | pair: null 95 | }, 96 | '\\section': { 97 | mode: 'text', 98 | type: 'start', 99 | pair: null 100 | }, 101 | '\\subsection': { 102 | mode: 'text', 103 | type: 'start', 104 | pair: null 105 | }, 106 | '\\subsubsection': { 107 | mode: 'text', 108 | type: 'start', 109 | pair: null 110 | }, 111 | '\\paragraph': { 112 | mode: 'text', 113 | type: 'start', 114 | pair: null 115 | }, 116 | '\\subparagraph': { 117 | mode: 'text', 118 | type: 'start', 119 | pair: null 120 | } 121 | } 122 | /* eslint-enable @typescript-eslint/naming-convention */ 123 | private allEnvRegex: RegExp 124 | 125 | constructor() { 126 | this.allEnvRegex = this.constructEnvRegexs() 127 | } 128 | 129 | private constructEnvRegexs() { 130 | function escapeRegExp(str: string) { 131 | return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 132 | } 133 | 134 | const regexStrs: string[] = [] 135 | 136 | const properStartEnvs: string[] = [] 137 | const properCloseEnvs: string[] = [] 138 | 139 | const properEnvPattern = /\\begin{(\w+)}/ 140 | for (const env of Object.keys(this.envs)) { 141 | const theEnv = this.envs[env] 142 | if (theEnv.type === 'end') { 143 | continue 144 | } 145 | const properEnvName = env.match(properEnvPattern) 146 | if (properEnvName) { 147 | properStartEnvs.push(properEnvName[1]) 148 | this.envs[`\\begin{${properEnvName[1]}*}`] = { 149 | mode: theEnv.mode, 150 | type: 'start', 151 | pair: `\\end{${properEnvName[1]}*}` 152 | } 153 | if (theEnv.pair !== null) { 154 | properCloseEnvs.push(properEnvName[1]) 155 | this.envs[`\\end{${properEnvName[1]}*}`] = { 156 | mode: theEnv.mode, 157 | type: 'end', 158 | pair: `\\begin{${properEnvName[1]}*}` 159 | } 160 | } 161 | } else { 162 | regexStrs.push(escapeRegExp(env)) 163 | if (theEnv.pair !== null) { 164 | regexStrs.push(escapeRegExp(theEnv.pair)) 165 | } 166 | } 167 | if (theEnv.pair !== null) { 168 | this.envs[theEnv.pair] = { 169 | mode: theEnv.mode, 170 | type: 'end', 171 | pair: env 172 | } 173 | } 174 | } 175 | 176 | regexStrs.push(`\\\\begin{(?:${properStartEnvs.join('|')})\\*?}`) 177 | regexStrs.push(`\\\\end{(?:${properCloseEnvs.join('|')})\\*?}`) 178 | 179 | return new RegExp(`(?:^|[^\\\\])(${regexStrs.join('|')})`, 'g') 180 | } 181 | 182 | public getTypeAtPosition( 183 | document: vscode.TextDocument, 184 | position: vscode.Position, 185 | lastKnown?: { position: vscode.Position, mode: 'maths' | 'text' } 186 | ): 'maths' | 'text' { 187 | const start = +new Date() 188 | 189 | let lineNo = position.line 190 | const tokenStack: string[] = [] 191 | 192 | let minLine = 0 193 | let minChar = -1 194 | if (lastKnown !== undefined && lastKnown.position.isBefore(position)) { 195 | minLine = lastKnown.position.line 196 | minChar = lastKnown.position.character 197 | } 198 | 199 | do { 200 | let lineContents = document.lineAt(lineNo--).text 201 | if (lineNo + 1 === position.line) { 202 | lineContents = lineContents.substr(0, position.character + 1) 203 | } 204 | lineContents = stripComments(lineContents, '%') 205 | // treat inside a comment as text 206 | if (lineNo + 1 === position.line && position.character > lineContents.length) { 207 | debuglog(start, position.line - lineNo, 'text', 'since it\'s a comment') 208 | return 'text' 209 | } 210 | 211 | let tokens: RegExpExecArray[] = [] 212 | let match: RegExpExecArray | null 213 | do { 214 | match = this.allEnvRegex.exec(lineContents) 215 | if (match !== null) { 216 | tokens.push(match) 217 | } 218 | } while (match) 219 | if (tokens.length === 0) { 220 | if (lineNo + 1 === minLine && 0 <= minChar && lastKnown) { 221 | // if last seen token closes the 'last known' environment, then we DON'T want to use it 222 | if (tokenStack.length > 0) { 223 | const lastEnv = this.envs[tokenStack[tokenStack.length - 1]] 224 | if (lastEnv.type === 'end' && lastEnv.mode === lastKnown.mode) { 225 | minLine = 0 226 | continue 227 | } 228 | } 229 | debuglog(start, position.line - lineNo, lastKnown.mode, 'using lastknown (1)') 230 | return lastKnown.mode 231 | } else { 232 | continue 233 | } 234 | } else { 235 | tokens = tokens.reverse() 236 | } 237 | 238 | let curlyBracketDepth = 0 239 | 240 | for (let i = 0; i < tokens.length; i++) { 241 | const token = tokens[i] 242 | const env = this.envs[token[1]] 243 | for (let j = lineContents.length - 1; j >= 0; j--) { 244 | if (token[1] === '\\text' && token.index === j) { 245 | if (curlyBracketDepth > 0) { 246 | debuglog(start, position.line - lineNo, env.mode, 'from \\text') 247 | return env.mode 248 | } 249 | } 250 | if (lineContents[j] === '}') { 251 | curlyBracketDepth-- 252 | } else if (lineContents[j] === '{') { 253 | curlyBracketDepth++ 254 | } 255 | } 256 | 257 | if (env.type === 'end') { 258 | if (env.pair === null) { 259 | debuglog(start, position.line - lineNo, env.mode, 'from env with no pair') 260 | return env.mode 261 | } else { 262 | tokenStack.push(token[1]) 263 | } 264 | } else if ( 265 | (tokenStack.length === 0 || tokenStack[tokenStack.length - 1] !== env.pair) && 266 | token[1] !== '\\text' 267 | ) { 268 | debuglog(start, position.line - lineNo, env.mode, 'from unpaired env token') 269 | return env.mode 270 | } else if (tokenStack.length > 0 && token[1] !== '\\text') { 271 | // this token matches the last seen token 272 | tokenStack.pop() 273 | // if it opens the env of last known then lastKnown is suspicious 274 | // this may be unnecessarily strict, think about this later 275 | if (lastKnown && env.mode === lastKnown.mode) { 276 | continue 277 | } 278 | } 279 | 280 | // if before a last known location 281 | if (lineNo + 1 === minLine && token.index < minChar && lastKnown) { 282 | // if last seen token closes the 'last known' environment, then we DON'T want to use it 283 | if (tokenStack.length > 0) { 284 | const lastEnv = this.envs[tokenStack[tokenStack.length - 1]] 285 | if (lastEnv.type === 'end' && lastEnv.mode === lastKnown.mode) { 286 | minLine = 0 287 | continue 288 | } 289 | } 290 | debuglog(start, position.line - lineNo, lastKnown.mode, 'using lastknown (2)') 291 | return lastKnown.mode 292 | } 293 | } 294 | } while (lineNo >= minLine) 295 | 296 | debuglog(start, position.line - lineNo, 'text', 'by default') 297 | return 'text' 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /src/components/wordCounter.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import * as fs from 'fs' 3 | import * as path from 'path' 4 | import * as cp from 'child_process' 5 | import * as util from 'util' 6 | 7 | import { Extension } from '../main' 8 | import { hasTexId } from '../utils' 9 | 10 | interface TexCount { 11 | words: { 12 | body: number 13 | headers: number 14 | captions: number 15 | } 16 | chars: { 17 | body: number 18 | headers: number 19 | captions: number 20 | } 21 | instances: { 22 | headers: number 23 | floats: number 24 | math: { 25 | inline: number 26 | displayed: number 27 | } 28 | } 29 | } 30 | 31 | export class WordCounter { 32 | extension: Extension 33 | status: vscode.StatusBarItem 34 | 35 | constructor(extension: Extension) { 36 | this.extension = extension 37 | this.status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, -10002) 38 | this.status.command = 'latex-utilities.selectWordcountFormat' 39 | this.setStatus() 40 | } 41 | 42 | async counts(merge = true, file = vscode.window.activeTextEditor?.document.fileName): Promise { 43 | if (file === undefined) { 44 | this.extension.logger.addLogMessage('A valid file was not give for TexCount') 45 | return 46 | } 47 | const configuration = vscode.workspace.getConfiguration('latex-utilities.countWord') 48 | const args = (configuration.get('args') as string[]).slice() 49 | const execFile = util.promisify(cp.execFile) 50 | if (merge) { 51 | args.push('-merge') 52 | } 53 | args.push('-brief') 54 | let command = configuration.get('path') as string 55 | if (configuration.get('docker.enabled')) { 56 | if (process.platform === 'win32') { 57 | command = path.resolve(this.extension.extensionRoot, './scripts/countword-win.bat') 58 | } else { 59 | command = path.resolve(this.extension.extensionRoot, './scripts/countword-linux.sh') 60 | fs.chmodSync(command, 0o755) 61 | } 62 | } 63 | this.extension.logger.addLogMessage(`TexCount args: ${args}`) 64 | let stdout; let stderr 65 | try { 66 | ({stdout, stderr} = await execFile(command, args.concat([path.basename(file)]), { 67 | cwd: path.dirname(file) 68 | })) 69 | 70 | } catch (err) { 71 | this.extension.logger.addLogMessage(`Cannot count words: ${err.message}, ${stderr}`) 72 | this.extension.logger.showErrorMessage( 73 | 'TeXCount failed. Please refer to LaTeX Utilities Output for details.' 74 | ) 75 | return undefined 76 | } 77 | // just get the last line, ignoring errors 78 | const stdoutWord = stdout 79 | .replace(/\(errors:\d+\)/, '') 80 | .split('\n') 81 | .map(l => l.trim()) 82 | .filter(l => l !== '') 83 | .slice(-1)[0] 84 | this.extension.logger.addLogMessage(`TeXCount output for word: ${stdout}`) 85 | args.push('-char') 86 | this.extension.logger.addLogMessage(`TexCount args: ${args}`) 87 | try { 88 | ({stdout, stderr} = await execFile(command, args.concat([path.basename(file)]), { 89 | cwd: path.dirname(file) 90 | })) 91 | } catch (err) { 92 | this.extension.logger.addLogMessage(`Cannot count words: ${err.message}, ${stderr}`) 93 | this.extension.logger.showErrorMessage( 94 | 'TeXCount failed. Please refer to LaTeX Utilities Output for details.' 95 | ) 96 | return undefined 97 | } 98 | const stdoutChar = stdout 99 | .replace(/\(errors:\d+\)/, '') 100 | .split('\n') 101 | .map(l => l.trim()) 102 | .filter(l => l !== '') 103 | .slice(-1)[0] 104 | this.extension.logger.addLogMessage(`TeXCount output for char: ${stdout}`) 105 | return this.parseTexCount(stdoutWord, stdoutChar) 106 | } 107 | 108 | parseTexCount(word: string, char: string): TexCount { 109 | const reMatchWord = /^(?\d+)\+(?\d+)\+(?\d+) \((?\d+)\/(?\d+)\/(?\d+)\/(?\d+)\)/.exec( 110 | word 111 | ) 112 | const reMatchChar = /^(?\d+)\+(?\d+)\+(?\d+) \((?\d+)\/(?\d+)\/(?\d+)\/(?\d+)\)/.exec( 113 | char 114 | ) 115 | if (reMatchWord !== null && reMatchChar !== null) { 116 | const { 117 | groups: { 118 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 119 | // @ts-ignore: ts _should_ be better with regex groups, but it isn't (yet) 120 | wordsBody, 121 | // @ts-ignore: ts _should_ be better with regex groups, but it isn't (yet) 122 | wordsHeaders, 123 | // @ts-ignore: ts _should_ be better with regex groups, but it isn't (yet) 124 | wordsCaptions, 125 | // @ts-ignore: ts _should_ be better with regex groups, but it isn't (yet) 126 | instancesHeaders, 127 | // @ts-ignore: ts _should_ be better with regex groups, but it isn't (yet) 128 | instancesFloats, 129 | // @ts-ignore: ts _should_ be better with regex groups, but it isn't (yet) 130 | mathInline, 131 | // @ts-ignore: ts _should_ be better with regex groups, but it isn't (yet) 132 | mathDisplayed 133 | /* eslint-enable @typescript-eslint/ban-ts-comment */ 134 | } 135 | } = reMatchWord 136 | 137 | const { 138 | groups: { 139 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 140 | // @ts-ignore: ts _should_ be better with regex groups, but it isn't (yet) 141 | charsBody, 142 | // @ts-ignore: ts _should_ be better with regex groups, but it isn't (yet) 143 | charsHeaders, 144 | // @ts-ignore: ts _should_ be better with regex groups, but it isn't (yet) 145 | charsCaptions, 146 | /* eslint-enable @typescript-eslint/ban-ts-comment */ 147 | } 148 | } = reMatchChar 149 | 150 | return { 151 | words: { 152 | body: parseInt(wordsBody), 153 | headers: parseInt(wordsHeaders), 154 | captions: parseInt(wordsCaptions) 155 | }, 156 | chars: { 157 | body: parseInt(charsBody), 158 | headers: parseInt(charsHeaders), 159 | captions: parseInt(charsCaptions) 160 | }, 161 | instances: { 162 | headers: parseInt(instancesHeaders), 163 | floats: parseInt(instancesFloats), 164 | math: { 165 | inline: parseInt(mathInline), 166 | displayed: parseInt(mathDisplayed) 167 | } 168 | } 169 | } 170 | } else { 171 | throw new Error('String was not valid TexCount output') 172 | } 173 | } 174 | 175 | async setStatus() { 176 | if ( 177 | vscode.window.activeTextEditor === undefined || 178 | !hasTexId(vscode.window.activeTextEditor.document.languageId) 179 | ) { 180 | this.status.hide() 181 | return 182 | } else { 183 | const template = vscode.workspace.getConfiguration('latex-utilities.countWord').get('format') as string 184 | if (template === '') { 185 | this.status.hide() 186 | return 187 | } 188 | const texCount = await this.counts(undefined, vscode.window.activeTextEditor.document.fileName) 189 | this.status.show() 190 | this.status.text = this.formatString(texCount, template) 191 | } 192 | } 193 | 194 | async pickFormat() { 195 | const texCount = await this.counts() 196 | 197 | const templates = [ 198 | '${words} Words', '${wordsBody} Words', '${chars} Chars', '${charsBody} Chars', '${headers} Headers', '${floats} Floats', '${math} Equations', 'custom'] 199 | const options: { [template: string]: string } = {} 200 | for (const template of templates) { 201 | options[template] = this.formatString(texCount, template) 202 | if (template.startsWith('${wordsBody}') || template.startsWith('${charsBody}')) { 203 | options[template] += ' (body only)' 204 | } 205 | } 206 | 207 | const choice = await vscode.window.showQuickPick(Object.values(options), { 208 | placeHolder: 'Select format to use' 209 | }) 210 | 211 | let format = choice 212 | if (choice === 'custom') { 213 | const currentFormat = vscode.workspace.getConfiguration('latex-utilities.countWord').get('format') as string 214 | format = await vscode.window.showInputBox({ 215 | placeHolder: 'Template', 216 | value: currentFormat, 217 | valueSelection: [0, currentFormat.length], 218 | prompt: 219 | 'The Template. Feel free to use the following placeholders: \ 220 | ${wordsBody}, ${wordsHeaders}, ${wordsCaptions}, ${words}, \ 221 | ${charsBody}, ${charsHeaders}, ${charsCaptions}, ${chars}, \ 222 | ${headers}, ${floats}, ${mathInline}, ${mathDisplayed}, ${math}' 223 | }) 224 | } else { 225 | for (const template in options) { 226 | if (options[template] === choice) { 227 | format = template 228 | break 229 | } 230 | } 231 | } 232 | 233 | if (format !== undefined) { 234 | vscode.workspace 235 | .getConfiguration('latex-utilities.countWord') 236 | .update('format', format, vscode.ConfigurationTarget.Global) 237 | .then(() => { 238 | setTimeout(() => { 239 | this.status.text = this.formatString(texCount, format as string) 240 | }, 300) 241 | }) 242 | } 243 | } 244 | 245 | formatString(texCount: TexCount | undefined, template: string) { 246 | if (texCount === undefined) { 247 | return '...' 248 | } 249 | /* eslint-disable @typescript-eslint/naming-convention */ 250 | const replacements: { [placeholder: string]: number } = { 251 | '${wordsBody}': texCount.words.body, 252 | '${wordsHeaders}': texCount.words.headers, 253 | '${wordsCaptions}': texCount.words.captions, 254 | '${charsBody}': texCount.chars.body, 255 | '${charsHeaders}': texCount.chars.headers, 256 | '${charsCaptions}': texCount.chars.captions, 257 | '${words}': texCount.words.body + texCount.words.headers + texCount.words.captions, 258 | '${chars}': texCount.chars.body + texCount.chars.headers + texCount.chars.captions, 259 | '${headers}': texCount.instances.headers, 260 | '${floats}': texCount.instances.floats, 261 | '${mathInline}': texCount.instances.math.inline, 262 | '${mathDisplayed}': texCount.instances.math.displayed, 263 | '${math}': texCount.instances.math.inline + texCount.instances.math.displayed 264 | } 265 | /* eslint-enable @typescript-eslint/naming-convention */ 266 | for (const placeholder in replacements) { 267 | template = template.replace(placeholder, replacements[placeholder].toString()) 268 | } 269 | return template 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /src/components/zotero.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import axios from 'axios' 3 | 4 | import { Extension } from '../main' 5 | 6 | export class Zotero { 7 | extension: Extension 8 | 9 | constructor(extension: Extension) { 10 | this.extension = extension 11 | } 12 | 13 | // Get a citation via the Zotero Cite as you Write popup 14 | private async caywCite() { 15 | const configuration = vscode.workspace.getConfiguration('latex-utilities.zotero') 16 | 17 | const zoteroUrl = configuration.get('zoteroUrl') as string 18 | 19 | const options = { 20 | format: 'biblatex', 21 | command: configuration.get('latexCommand') 22 | } 23 | 24 | try { 25 | const res = await axios.get(`${zoteroUrl}/better-bibtex/cayw`, { 26 | params: options 27 | }) 28 | 29 | return res.data 30 | } catch (error) { 31 | if (error.code === 'ECONNREFUSED') { 32 | this.extension.logger.showErrorMessage( 33 | 'Could not connect to Zotero. Is it running with the Better BibTeX extension installed?' 34 | ) 35 | } else { 36 | this.extension.logger.addLogMessage(`Cannot insert citation: ${error.message}`) 37 | this.extension.logger.showErrorMessage( 38 | 'Cite as you write failed. Please refer to LaTeX Utilities Output for details.' 39 | ) 40 | } 41 | 42 | return null 43 | } 44 | } 45 | 46 | // Search the Zotero library for entries matching `terms`. 47 | // Returns a promise for search results and a function to cancel the search 48 | private search(terms: string): [Promise, () => void] { 49 | const configuration = vscode.workspace.getConfiguration('latex-utilities.zotero') 50 | const zoteroUrl = configuration.get('zoteroUrl') as string 51 | 52 | this.extension.logger.addLogMessage(`Searching Zotero for "${terms}"`) 53 | const CancelToken = axios.CancelToken 54 | const source = CancelToken.source() 55 | const req = axios(`${zoteroUrl}/better-bibtex/json-rpc`, { 56 | method: 'post', 57 | data: { 58 | jsonrpc: '2.0', 59 | method: 'item.search', 60 | params: [ 61 | [['ignore_feeds'], ['quicksearch-titleCreatorYear', 'contains', terms]] 62 | ], 63 | }, 64 | responseType: 'json', 65 | cancelToken: source.token 66 | }) 67 | 68 | return [ 69 | req.then(response => { 70 | console.log(response) 71 | const results = response.data.result as SearchResult[] 72 | this.extension.logger.addLogMessage(`Got ${results.length} search results from Zotero for "${terms}"`) 73 | return results 74 | }).catch(error => { 75 | this.extension.logger.showErrorMessage(`Searching Zotero failed: ${error.message}`) 76 | throw error 77 | }), 78 | () => source.cancel('request canceled') 79 | ] 80 | } 81 | 82 | // Get a citation from a built-in quick picker 83 | private async vscodeCite() { 84 | const disposables: vscode.Disposable[] = [] 85 | 86 | try { 87 | const entries = await new Promise((resolve, _) => { 88 | const input = vscode.window.createQuickPick() 89 | input.matchOnDescription = true 90 | input.matchOnDetail = true 91 | input.canSelectMany = true 92 | input.placeholder = 'Type to insert citations' 93 | 94 | let cancel: (() => void) | undefined 95 | 96 | disposables.push( 97 | input.onDidChangeValue((value: any) => { 98 | if (value) { 99 | this.extension.logger.addLogMessage(`${input.busy}`) 100 | input.busy = true 101 | 102 | if (cancel) { 103 | cancel() 104 | cancel = undefined 105 | } 106 | 107 | const [r, c] = this.search(value) 108 | cancel = c 109 | r.then(results => { 110 | console.log('results') 111 | input.items = results.map(result => new EntryItem(result)) 112 | input.busy = false 113 | }) 114 | .catch(error => { 115 | if (!error.isCanceled) { 116 | if (error.code === 'ECONNREFUSED') { 117 | this.extension.logger.showErrorMessage( 118 | 'Could not connect to Zotero. Is it running with the Better BibTeX extension installed?' 119 | ) 120 | } else { 121 | this.extension.logger.addLogMessage( 122 | `Searching Zotero failed: ${error.message}` 123 | ) 124 | input.items = [new ErrorItem(error)] 125 | } 126 | } 127 | }) 128 | .finally(() => { 129 | }) 130 | } else { 131 | input.items = [] 132 | } 133 | }) 134 | ) 135 | 136 | disposables.push( 137 | input.onDidAccept(() => { 138 | const items = input.selectedItems.length > 0 ? input.selectedItems : input.activeItems 139 | input.hide() 140 | resolve(items.filter((i: any) => i instanceof EntryItem).map((i: any) => (i as EntryItem).result)) 141 | }) 142 | ) 143 | 144 | disposables.push( 145 | input.onDidHide(() => { 146 | if (cancel) { 147 | cancel() 148 | } 149 | 150 | resolve([]) 151 | input.dispose() 152 | }) 153 | ) 154 | 155 | input.show() 156 | }) 157 | 158 | if (entries && entries.length > 0) { 159 | const configuration = vscode.workspace.getConfiguration('latex-utilities.zotero') 160 | const latexCommand = configuration.get('latexCommand') as string 161 | 162 | const keys = entries.map(e => e.citekey).join(',') 163 | return latexCommand.length > 0 ?`\\${latexCommand}{${keys}}` : `${keys}` 164 | } else { 165 | return null 166 | } 167 | } finally { 168 | disposables.forEach(d => d.dispose()) 169 | } 170 | } 171 | 172 | private async insertCitation(citation: string) { 173 | const editor = vscode.window.activeTextEditor 174 | if (editor) { 175 | await editor.edit((edit: any) => { 176 | if (editor.selection.isEmpty) { 177 | edit.insert(editor.selection.active, citation) 178 | } else { 179 | edit.delete(editor.selection) 180 | edit.insert(editor.selection.start, citation) 181 | } 182 | }) 183 | 184 | this.extension.logger.addLogMessage(`Added citation: ${citation}`) 185 | } else { 186 | this.extension.logger.addLogMessage('Could not insert citation: no active text editor') 187 | } 188 | } 189 | 190 | async cite() { 191 | const configuration = vscode.workspace.getConfiguration('latex-utilities.zotero') 192 | const citeMethod = configuration.get('citeMethod') 193 | 194 | if (!(await this.checkZotero())) { 195 | return 196 | } 197 | 198 | let citation = null 199 | if (citeMethod === 'zotero') { 200 | citation = await this.caywCite() 201 | } else if (citeMethod === 'vscode') { 202 | citation = await this.vscodeCite() 203 | } else { 204 | this.extension.logger.showErrorMessage(`Unknown cite method: ${citeMethod}`) 205 | } 206 | 207 | if (citation) { 208 | this.insertCitation(citation) 209 | } 210 | 211 | this.extension.telemetryReporter.sendTelemetryEvent('zoteroCite') 212 | } 213 | 214 | private extractCiteKey(editor: vscode.TextEditor) { 215 | if (editor.selection.isEmpty) { 216 | const range = editor.document.getWordRangeAtPosition(editor.selection.active) 217 | return editor.document.getText(range) 218 | } else { 219 | return editor.document.getText(new vscode.Range(editor.selection.start, editor.selection.end)) 220 | } 221 | } 222 | 223 | async openCitation() { 224 | if (!(await this.checkZotero())) { 225 | return 226 | } 227 | 228 | const editor = vscode.window.activeTextEditor 229 | if (!editor) { 230 | return 231 | } 232 | 233 | const citeKey = this.extractCiteKey(editor) 234 | this.extension.logger.addLogMessage(`Opening ${citeKey} in Zotero`) 235 | 236 | const uri = vscode.Uri.parse(`zotero://select/items/bbt:${citeKey}`) 237 | await vscode.env.openExternal(uri) 238 | } 239 | 240 | private async checkZotero() { 241 | const configuration = vscode.workspace.getConfiguration('latex-utilities.zotero') 242 | const zoteroUrl = configuration.get('zoteroUrl') as string 243 | 244 | try { 245 | await axios.get(`${zoteroUrl}/connector/ping`) 246 | return true 247 | } catch (e) { 248 | if (e.code === 'ECONNREFUSED') { 249 | vscode.window.showWarningMessage('Zotero doesn\'t appear to be running.') 250 | return false 251 | } 252 | } 253 | return false 254 | } 255 | } 256 | 257 | // Better BibTeX search result 258 | interface SearchResult { 259 | type: string 260 | citekey: string 261 | title: string 262 | author?: [{ family: string, given: string }] 263 | [field: string]: any 264 | } 265 | 266 | class EntryItem implements vscode.QuickPickItem { 267 | label: string 268 | detail: string 269 | description: string 270 | 271 | constructor(public result: SearchResult) { 272 | this.label = result.title 273 | this.detail = result.citekey 274 | 275 | if (result.author) { 276 | const names = result.author.map(a => `${a.given} ${a.family}`) 277 | 278 | if (names.length < 2) { 279 | this.description = names.join(' ') 280 | } else if (names.length === 2) { 281 | this.description = names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1] 282 | } else { 283 | this.description = names.slice(0, -1).join(', ') + ', and ' + names[names.length - 1] 284 | } 285 | } else { 286 | this.description = '' 287 | } 288 | } 289 | } 290 | 291 | class ErrorItem implements vscode.QuickPickItem { 292 | label: string 293 | 294 | constructor(public message: string) { 295 | this.label = message.replace(/\r?\n/g, ' ') 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import * as path from 'path' 3 | import * as fs from 'fs' 4 | 5 | import { Logger } from './components/logger' 6 | import { CompletionWatcher, Completer } from './components/completionWatcher' 7 | import { Paster } from './components/paster' 8 | import { WordCounter } from './components/wordCounter' 9 | import { MacroDefinitions } from './providers/macroDefinitions' 10 | import { Zotero } from './components/zotero' 11 | 12 | import TelemetryReporter from 'vscode-extension-telemetry' 13 | import { Manager } from './workshop/manager' 14 | 15 | let extension: Extension 16 | 17 | export function activate(context: vscode.ExtensionContext) { 18 | extension = new Extension() 19 | 20 | extension.logger.addLogMessage('LaTeX Utilities Started') 21 | 22 | context.subscriptions.push( 23 | vscode.commands.registerCommand('latex-utilities.loadPlugin', () => 24 | vscode.window.showInformationMessage( 25 | 'LaTeX Utilities loaded' 26 | ) 27 | ), 28 | vscode.commands.registerCommand('latex-utilities.editLiveSnippetsFile', () => 29 | extension.withTelemetry('editLiveSnippetsFile', () => { 30 | extension.completionWatcher.editSnippetsFile() 31 | }) 32 | ), 33 | vscode.commands.registerCommand('latex-utilities.formattedPaste', () => 34 | extension.withTelemetry('formattedPaste', () => { 35 | extension.paster.paste() 36 | }) 37 | ), 38 | vscode.commands.registerCommand('latex-utilities.citeZotero', () => 39 | extension.withTelemetry('citeZotero', () => { 40 | extension.zotero.cite() 41 | }) 42 | ), 43 | vscode.commands.registerCommand('latex-utilities.openInZotero', () => 44 | extension.withTelemetry('openInZotero', () => { 45 | extension.zotero.openCitation() 46 | }) 47 | ), 48 | vscode.commands.registerCommand('latex-utilities.selectWordcountFormat', () => 49 | extension.withTelemetry('selectWordcountFormat', () => { 50 | extension.wordCounter.pickFormat() 51 | }) 52 | ), 53 | ) 54 | 55 | context.subscriptions.push( 56 | vscode.workspace.onDidChangeTextDocument( 57 | (e: vscode.TextDocumentChangeEvent) => { 58 | extension.withTelemetry('onDidChangeTextDocument', () => extension.completionWatcher.watcher(e), false) 59 | } 60 | ), 61 | vscode.workspace.onDidSaveTextDocument(() => { 62 | extension.withTelemetry('onDidSaveTextDocument_tex_wordcounter', () => extension.wordCounter.setStatus()) 63 | }), 64 | vscode.window.onDidChangeActiveTextEditor((_e: vscode.TextEditor | undefined) => { 65 | extension.withTelemetry('onDidChangeActiveTextEditor_tex_wordcounter', () => extension.wordCounter.setStatus()) 66 | }) 67 | ) 68 | 69 | context.subscriptions.push( 70 | vscode.languages.registerCompletionItemProvider({ scheme: 'file', language: 'tex' }, extension.completer), 71 | vscode.languages.registerCompletionItemProvider({ scheme: 'file', language: 'latex' }, extension.completer), 72 | vscode.languages.registerCompletionItemProvider({ scheme: 'file', language: 'doctex' }, extension.completer), 73 | vscode.languages.registerCompletionItemProvider({ scheme: 'file', language: 'rsweave' }, extension.completer), 74 | vscode.languages.registerCompletionItemProvider({ scheme: 'file', language: 'jlweave' }, extension.completer), 75 | vscode.languages.registerDefinitionProvider( 76 | { language: 'latex', scheme: 'file' }, 77 | new MacroDefinitions(extension) 78 | ) 79 | ) 80 | 81 | newVersionMessage(context.extensionPath) 82 | context.subscriptions.push(extension.telemetryReporter) 83 | } 84 | 85 | export function deactivate() { 86 | extension.telemetryReporter.dispose() 87 | } 88 | 89 | function newVersionMessage(extensionPath: string) { 90 | fs.readFile(`${extensionPath}${path.sep}package.json`, (err, data) => { 91 | if (err) { 92 | extension.logger.addLogMessage('Cannot read package information.') 93 | return 94 | } 95 | extension.packageInfo = JSON.parse(data.toString()) 96 | extension.logger.addLogMessage(`LaTeX Utilities version: ${extension.packageInfo.version}`) 97 | if ( 98 | fs.existsSync(`${extensionPath}${path.sep}VERSION`) && 99 | fs.readFileSync(`${extensionPath}${path.sep}VERSION`).toString() === extension.packageInfo.version 100 | ) { 101 | return 102 | } 103 | fs.writeFileSync(`${extensionPath}${path.sep}VERSION`, extension.packageInfo.version) 104 | const configuration = vscode.workspace.getConfiguration('latex-utilities') 105 | if (!(configuration.get('message.update.show') as boolean)) { 106 | return 107 | } 108 | vscode.window 109 | .showInformationMessage( 110 | `LaTeX Utilities updated to version ${extension.packageInfo.version}.`, 111 | 'Change log', 112 | 'Star the project', 113 | 'Disable this message forever' 114 | ) 115 | .then(option => { 116 | switch (option) { 117 | case 'Change log': 118 | vscode.commands.executeCommand( 119 | 'markdown.showPreview', 120 | vscode.Uri.file(`${extensionPath}${path.sep}CHANGELOG.md`) 121 | ) 122 | break 123 | case 'Star the project': 124 | vscode.commands.executeCommand( 125 | 'vscode.open', 126 | vscode.Uri.parse('https://github.com/tecosaur/LaTeX-Utilities/') 127 | ) 128 | break 129 | case 'Disable this message forever': 130 | configuration.update('message.update.show', false, true) 131 | break 132 | default: 133 | break 134 | } 135 | }) 136 | }) 137 | } 138 | 139 | export class Extension { 140 | extensionRoot: string 141 | packageInfo: any 142 | telemetryReporter: TelemetryReporter 143 | // workshop: LaTeXWorkshopAPI 144 | logger: Logger 145 | completionWatcher: CompletionWatcher 146 | completer: Completer 147 | paster: Paster 148 | wordCounter: WordCounter 149 | zotero: Zotero 150 | manager: Manager 151 | 152 | constructor() { 153 | this.extensionRoot = path.resolve(`${__dirname}/../`) 154 | const self = vscode.extensions.getExtension('tecosaur.latex-utilities') as vscode.Extension 155 | this.telemetryReporter = new TelemetryReporter( 156 | 'tecosaur.latex-utilities', 157 | self.packageJSON.version, 158 | '11a955d7-02dc-4c1a-85e4-053858f88af0' 159 | ) 160 | // const workshop = vscode.extensions.getExtension('james-yu.latex-workshop') as vscode.Extension 161 | // this.workshop = workshop.exports 162 | // if (workshop.isActive === false) { 163 | // workshop.activate().then(() => (this.workshop = workshop.exports)) 164 | // } 165 | this.logger = new Logger(this) 166 | this.completionWatcher = new CompletionWatcher(this) 167 | this.completer = new Completer(this) 168 | this.paster = new Paster(this) 169 | this.wordCounter = new WordCounter(this) 170 | this.zotero = new Zotero(this) 171 | this.manager = new Manager(this) 172 | } 173 | 174 | withTelemetry(command: string, callback: () => void, log: boolean = true) { 175 | if (log){ 176 | this.logger.addLogMessage('withTelemetry: ' + command) 177 | } 178 | try { 179 | callback() 180 | } catch (error) { 181 | this.logger.addLogMessage(error) 182 | this.telemetryReporter.sendTelemetryException(error, { 183 | command 184 | }) 185 | this.logger.addLogMessage('Error reported.') 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/providers/macroDefinitions.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { Extension } from '../main' 3 | import { checkCommandExists } from '../utils' 4 | import { spawn } from 'child_process' 5 | 6 | export class MacroDefinitions implements vscode.DefinitionProvider { 7 | extension: Extension 8 | 9 | constructor(extension: Extension) { 10 | this.extension = extension 11 | } 12 | 13 | async provideDefinition( 14 | document: vscode.TextDocument, 15 | position: vscode.Position, 16 | _token: vscode.CancellationToken 17 | ) { 18 | try { 19 | const enabled = vscode.workspace.getConfiguration('latex-utilities.texdef').get('enabled') 20 | if (!enabled) { 21 | return 22 | } 23 | 24 | const line = document.lineAt(position.line) 25 | let command: vscode.Range | undefined 26 | 27 | const pattern = /\\[\w@]+/g 28 | let match = pattern.exec(line.text) 29 | while (match !== null) { 30 | const matchStart = line.range.start.translate(0, match.index) 31 | const matchEnd = matchStart.translate(0, match[0].length) 32 | const matchRange = new vscode.Range(matchStart, matchEnd) 33 | 34 | if (matchRange.contains(position)) { 35 | command = matchRange 36 | break 37 | } 38 | match = pattern.exec(line.text) 39 | } 40 | 41 | if (command === undefined) { 42 | return 43 | } 44 | 45 | checkCommandExists('texdef') 46 | 47 | const texdefOptions = ['--source', '--Find', '--tex', 'latex'] 48 | const packages = this.extension.manager.usedPackages(document) 49 | if (/\.sty$/.test(document.uri.fsPath)) { 50 | texdefOptions.push(document.uri.fsPath.replace(/\.sty$/, '')) 51 | } 52 | texdefOptions.push(...[...packages].map(p => ['-p', p]).reduce((prev, next) => prev.concat(next), [])) 53 | const documentClass = this.getDocumentClass(document) 54 | texdefOptions.push('--class', documentClass !== null ? documentClass : 'article') 55 | texdefOptions.push(document.getText(command)) 56 | 57 | const texdefResult = await this.getFirstLineOfOutput('texdef', texdefOptions) 58 | 59 | const resultPattern = /% (.+), line (\d+):/ 60 | let result: RegExpMatchArray | null 61 | if ((result = texdefResult.match(resultPattern)) !== null) { 62 | this.extension.telemetryReporter.sendTelemetryEvent('texdef') 63 | return new vscode.Location(vscode.Uri.file(result[1]), new vscode.Position(parseInt(result[2]) - 1, 0)) 64 | } else { 65 | vscode.window.showWarningMessage(`Could not find definition for ${document.getText(command)}`) 66 | this.extension.logger.addLogMessage(`Could not find definition for ${document.getText(command)}`) 67 | return 68 | } 69 | } catch (error) { 70 | this.extension.logger.addLogMessage(error) 71 | this.extension.telemetryReporter.sendTelemetryException(error, { 72 | 'command': 'MacroDefinitions.provideDefinition', 73 | }) 74 | this.extension.logger.addLogMessage('Error reported.') 75 | } 76 | } 77 | 78 | private getDocumentClass(document: vscode.TextDocument): string | null { 79 | const documentClassPattern = /\\documentclass((?:\[[\w-,]*\])?{[\w-]+)}/ 80 | let documentClass: RegExpMatchArray | null 81 | let line = 0 82 | while (line < 50 && line < document.lineCount) { 83 | const lineContents = document.lineAt(line++).text 84 | if ((documentClass = lineContents.match(documentClassPattern)) !== null) { 85 | return documentClass[1].replace(/{([\w-]+)$/, '$1') 86 | } 87 | } 88 | return null 89 | } 90 | 91 | private async getFirstLineOfOutput(command: string, options: string[]): Promise { 92 | return new Promise(resolve => { 93 | const startTime = +new Date() 94 | this.extension.logger.addLogMessage(`Running command ${command} ${options.join(' ')}`) 95 | try { 96 | const cmdProcess = spawn(command, options) 97 | 98 | cmdProcess.stdout.on('data', data => { 99 | this.extension.logger.addLogMessage( 100 | `Took ${+new Date() - startTime}ms to find definition for ${options[options.length - 1]}` 101 | ) 102 | cmdProcess.kill() 103 | resolve(data.toString()) 104 | }) 105 | cmdProcess.stdout.on('error', () => { 106 | this.extension.logger.addLogMessage(`Error running texdef for ${options[options.length - 1]}}`) 107 | resolve('') 108 | }) 109 | cmdProcess.stdout.on('end', () => { 110 | resolve('') 111 | }) 112 | setTimeout(() => { 113 | cmdProcess.kill() 114 | }, 6000) 115 | } catch (error) { 116 | this.extension.logger.showErrorMessage(`Got ${error} while running texdef. Is texdef installed?`) 117 | } 118 | }) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { execSync } from 'child_process' 3 | 4 | /** 5 | * Remove the comments if any 6 | */ 7 | export function stripComments(text: string, commentSign: string): string { 8 | const pattern = '([^\\\\]|^)' + commentSign + '.*$' 9 | const reg = RegExp(pattern, 'gm') 10 | return text.replace(reg, '$1') 11 | } 12 | 13 | /** 14 | * @param id document languageId 15 | */ 16 | export function hasTexId(id: string) { 17 | return id === 'tex' || id === 'latex' || id === 'doctex' || id === 'rsweave' || id === 'jlweave' 18 | } 19 | 20 | export function checkCommandExists(command: string) { 21 | try { 22 | execSync(`${command} --version`) 23 | } catch (error) { 24 | if (error.status === 127) { 25 | vscode.window.showErrorMessage(`Command ${command} not found`) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/workshop/LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 James Yu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/workshop/finderutils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import * as path from 'path' 3 | import * as utils from './utils' 4 | import * as fs from 'fs' 5 | 6 | import type {Extension} from '../main' 7 | 8 | export class FinderUtils { 9 | private readonly extension: Extension 10 | 11 | readFileSyncGracefully(filepath: string): string | undefined { 12 | try { 13 | const ret = fs.readFileSync(filepath).toString() 14 | return ret 15 | } catch (err) { 16 | if (err instanceof Error) { 17 | this.extension.logger.logError(err) 18 | } 19 | return undefined 20 | } 21 | } 22 | 23 | constructor(extension: Extension) { 24 | this.extension = extension 25 | } 26 | 27 | findRootFromMagic(): string | undefined { 28 | if (!vscode.window.activeTextEditor) { 29 | return undefined 30 | } 31 | const regex = /^(?:%\s*!\s*T[Ee]X\sroot\s*=\s*(.*\.(?:tex|[jrsRS]nw|[rR]tex|jtexw))$)/m 32 | let content: string | undefined = vscode.window.activeTextEditor.document.getText() 33 | 34 | let result = content.match(regex) 35 | const fileStack: string[] = [] 36 | if (result) { 37 | let file = path.resolve(path.dirname(vscode.window.activeTextEditor.document.fileName), result[1]) 38 | content = this.readFileSyncGracefully(file) 39 | if (content === undefined) { 40 | const msg = `Not found root file specified in the magic comment: ${file}` 41 | this.extension.logger.addLogMessage(msg) 42 | throw new Error(msg) 43 | } 44 | fileStack.push(file) 45 | this.extension.logger.addLogMessage(`Found root file by magic comment: ${file}`) 46 | 47 | result = content.match(regex) 48 | while (result) { 49 | file = path.resolve(path.dirname(file), result[1]) 50 | if (fileStack.includes(file)) { 51 | this.extension.logger.addLogMessage(`Looped root file by magic comment found: ${file}, stop here.`) 52 | return file 53 | } else { 54 | fileStack.push(file) 55 | this.extension.logger.addLogMessage(`Recursively found root file by magic comment: ${file}`) 56 | } 57 | 58 | content = this.readFileSyncGracefully(file) 59 | if (content === undefined) { 60 | const msg = `Not found root file specified in the magic comment: ${file}` 61 | this.extension.logger.addLogMessage(msg) 62 | throw new Error(msg) 63 | 64 | } 65 | result = content.match(regex) 66 | } 67 | return file 68 | } 69 | return undefined 70 | } 71 | 72 | findSubFiles(content: string): string | undefined { 73 | if (!vscode.window.activeTextEditor) { 74 | return undefined 75 | } 76 | const regex = /(?:\\documentclass\[(.*)\]{subfiles})/ 77 | const result = content.match(regex) 78 | if (result) { 79 | const file = utils.resolveFile([path.dirname(vscode.window.activeTextEditor.document.fileName)], result[1]) 80 | if (file) { 81 | this.extension.logger.addLogMessage(`Found root file of this subfile from active editor: ${file}`) 82 | } else { 83 | this.extension.logger.addLogMessage(`Cannot find root file of this subfile from active editor: ${result[1]}`) 84 | } 85 | return file 86 | } 87 | return undefined 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/workshop/manager.ts: -------------------------------------------------------------------------------- 1 | // from James-Yu/LaTeX-Workshop 2 | 3 | import { Extension } from '../main' 4 | import * as vscode from 'vscode' 5 | import * as path from 'path' 6 | import * as fs from 'fs' 7 | import * as utils from './utils' 8 | import * as tmp from 'tmp' 9 | import {FinderUtils} from './finderutils' 10 | import type {MatchPath} from './pathutils' 11 | import {PathUtils, PathRegExp} from './pathutils' 12 | 13 | 14 | /** 15 | * The content cache for each LaTeX file `filepath`. 16 | */ 17 | interface Content { 18 | [filepath: string]: { // The path of a LaTeX file. 19 | /** 20 | * The dirty (under editing) content of the LaTeX file if opened in vscode, 21 | * the content on disk otherwise. 22 | */ 23 | content: string | undefined 24 | /** 25 | * The sub-files of the LaTeX file. They should be tex or plain files. 26 | */ 27 | children: { 28 | /** 29 | * The index of character sub-content is inserted 30 | */ 31 | index: number 32 | /** 33 | * The path of the sub-file 34 | */ 35 | file: string 36 | }[] 37 | /** 38 | * The array of the paths of `.bib` files referenced from the LaTeX file. 39 | */ 40 | bibs: string[] 41 | } 42 | } 43 | 44 | 45 | type RootFileType = { 46 | type: 'filePath' 47 | filePath: string 48 | } | { 49 | type: 'uri' 50 | uri: vscode.Uri 51 | } 52 | 53 | export class Manager { 54 | /** 55 | * The content cache for each LaTeX file. 56 | */ 57 | private readonly cachedContent = Object.create(null) as Content 58 | 59 | private readonly localRootFiles = Object.create(null) as { [key: string]: string | undefined } 60 | private readonly rootFilesLanguageIds = Object.create(null) as { [key: string]: string | undefined } 61 | // Store one root file for each workspace. 62 | private readonly rootFiles = Object.create(null) as { [key: string]: RootFileType | undefined } 63 | private workspaceRootDirUri = '' 64 | 65 | private readonly extension: Extension 66 | private readonly rsweaveExt: string[] = ['.rnw', '.Rnw', '.rtex', '.Rtex', '.snw', '.Snw'] 67 | private readonly jlweaveExt: string[] = ['.jnw', '.jtexw'] 68 | private readonly finderUtils: FinderUtils 69 | private readonly pathUtils: PathUtils 70 | private tmpDir: string 71 | 72 | constructor(extension: Extension) { 73 | this.extension = extension 74 | this.finderUtils = new FinderUtils(extension) 75 | this.pathUtils = new PathUtils(extension) 76 | this.tmpDir = tmp.dirSync({unsafeCleanup: true}).name.split(path.sep).join('/') 77 | } 78 | 79 | /** 80 | * Returns the output directory developed according to the input tex path 81 | * and 'latex.outDir' config. If `texPath` is `undefined`, the default root 82 | * file is used. If there is not root file, returns './'. 83 | * The returned path always uses `/` even on Windows. 84 | * 85 | * @param texPath The path of a LaTeX file. 86 | */ 87 | getOutDir(texPath?: string) { 88 | if (texPath === undefined) { 89 | texPath = this.rootFile 90 | } 91 | // rootFile is also undefined 92 | if (texPath === undefined) { 93 | return './' 94 | } 95 | 96 | const configuration = vscode.workspace.getConfiguration('latex-workshop') 97 | const outDir = configuration.get('latex.outDir') as string 98 | const out = utils.replaceArgumentPlaceholders(texPath, this.tmpDir)(outDir) 99 | return path.normalize(out).split(path.sep).join('/') 100 | } 101 | 102 | 103 | /** 104 | * The path of the directory of the root file. 105 | */ 106 | get rootDir() { 107 | return this.rootFile ? path.dirname(this.rootFile) : undefined 108 | } 109 | 110 | /** 111 | * The path of the root LaTeX file of the current workspace. 112 | * It is `undefined` before `findRoot` called. 113 | */ 114 | get rootFile(): string | undefined { 115 | const ret = this.rootFiles[this.workspaceRootDirUri] 116 | if (ret) { 117 | if (ret.type === 'filePath') { 118 | return ret.filePath 119 | } else { 120 | if (ret.uri.scheme === 'file') { 121 | return ret.uri.fsPath 122 | } else { 123 | this.extension.logger.addLogMessage(`The file cannot be used as the root file: ${ret.uri.toString(true)}`) 124 | return 125 | } 126 | } 127 | } else { 128 | return 129 | } 130 | } 131 | 132 | set rootFile(root: string | undefined) { 133 | if (root) { 134 | this.rootFiles[this.workspaceRootDirUri] = { type: 'filePath', filePath: root } 135 | } else { 136 | this.rootFiles[this.workspaceRootDirUri] = undefined 137 | } 138 | } 139 | 140 | get rootFileUri(): vscode.Uri | undefined { 141 | const root = this.rootFiles[this.workspaceRootDirUri] 142 | if (root) { 143 | if (root.type === 'filePath') { 144 | return vscode.Uri.file(root.filePath) 145 | } else { 146 | return root.uri 147 | } 148 | } else { 149 | return 150 | } 151 | } 152 | 153 | set rootFileUri(root: vscode.Uri | undefined) { 154 | let rootFile: RootFileType | undefined 155 | if (root) { 156 | if (root.scheme === 'file') { 157 | rootFile = { type: 'filePath', filePath: root.fsPath } 158 | } else { 159 | rootFile = { type: 'uri', uri: root } 160 | } 161 | } 162 | this.rootFiles[this.workspaceRootDirUri] = rootFile 163 | } 164 | 165 | get localRootFile() { 166 | return this.localRootFiles[this.workspaceRootDirUri] 167 | } 168 | 169 | set localRootFile(localRoot: string | undefined) { 170 | this.localRootFiles[this.workspaceRootDirUri] = localRoot 171 | } 172 | 173 | get rootFileLanguageId() { 174 | return this.rootFilesLanguageIds[this.workspaceRootDirUri] 175 | } 176 | 177 | set rootFileLanguageId(id: string | undefined) { 178 | this.rootFilesLanguageIds[this.workspaceRootDirUri] = id 179 | } 180 | 181 | /** 182 | * Return a string array which holds all imported tex files 183 | * from the given `file` including the `file` itself. 184 | * If `file` is `undefined`, trace from the * root file, 185 | * or return empty array if the root file is `undefined` 186 | * 187 | * @param file The path of a LaTeX file 188 | */ 189 | getIncludedTeX(file?: string, includedTeX: string[] = []): string[] { 190 | if (file === undefined) { 191 | file = this.rootFile 192 | } 193 | if (file === undefined) { 194 | return [] 195 | } 196 | if (!(file in this.extension.manager.cachedContent)) { 197 | return [] 198 | } 199 | includedTeX.push(file) 200 | for (const child of this.extension.manager.cachedContent[file].children) { 201 | if (includedTeX.includes(child.file)) { 202 | // Already included 203 | continue 204 | } 205 | this.getIncludedTeX(child.file, includedTeX) 206 | } 207 | return includedTeX 208 | } 209 | 210 | usedPackages(document: vscode.TextDocument) { 211 | // slower but will do the work for now 212 | const text = document.getText() 213 | const allPkgs: Set = new Set() 214 | // use regex to find all \usepackage{} 215 | const pkgs = text.match(/\\usepackage\{(.*?)\}/g) 216 | if (pkgs) { 217 | for (const pkg of pkgs) { 218 | const pkgName = pkg.replace(/\\usepackage\{(.*?)\}/, '$1') 219 | allPkgs.add(pkgName) 220 | } 221 | this.extension.logger.addLogMessage(`${pkgs}`) 222 | } 223 | return allPkgs 224 | } 225 | 226 | getGraphicsPath(content: string): string[] { 227 | const graphicsPath: string[] = [] 228 | const regex = /\\graphicspath{[\s\n]*((?:{[^{}]*}[\s\n]*)*)}/g 229 | const noVerbContent = utils.stripCommentsAndVerbatim(content) 230 | let result: string[] | null 231 | do { 232 | result = regex.exec(noVerbContent) 233 | if (result) { 234 | for (const dir of result[1].split(/\{|\}/).filter(s => s.replace(/^\s*$/, ''))) { 235 | if (graphicsPath.includes(dir)) { 236 | continue 237 | } 238 | graphicsPath.push(dir) 239 | } 240 | } 241 | } while (result) 242 | return graphicsPath 243 | } 244 | 245 | getCachedContent(filePath: string): Content[string] | undefined { 246 | return this.cachedContent[filePath] 247 | } 248 | 249 | private inferLanguageId(filename: string): string | undefined { 250 | const ext = path.extname(filename).toLocaleLowerCase() 251 | if (ext === '.tex') { 252 | return 'latex' 253 | } else if (this.jlweaveExt.includes(ext)) { 254 | return 'jlweave' 255 | } else if (this.rsweaveExt.includes(ext)) { 256 | return 'rsweave' 257 | } else { 258 | return undefined 259 | } 260 | } 261 | 262 | /** 263 | * Returns `true` if the language of `id` is one of supported languages. 264 | * 265 | * @param id The identifier of language. 266 | */ 267 | hasTexId(id: string) { 268 | return ['tex', 'latex', 'latex-expl3', 'doctex', 'jlweave', 'rsweave'].includes(id) 269 | } 270 | 271 | private findWorkspace() { 272 | const firstDir = vscode.workspace.workspaceFolders?.[0] 273 | // If no workspace is opened. 274 | if (!firstDir) { 275 | this.workspaceRootDirUri = '' 276 | return 277 | } 278 | // If we don't have an active text editor, we can only make a guess. 279 | // Let's guess the first one. 280 | if (!vscode.window.activeTextEditor) { 281 | this.workspaceRootDirUri = firstDir.uri.toString(true) 282 | return 283 | } 284 | // Get the workspace folder which contains the active document. 285 | const activeFileUri = vscode.window.activeTextEditor.document.uri 286 | const workspaceFolder = vscode.workspace.getWorkspaceFolder(activeFileUri) 287 | if (workspaceFolder) { 288 | this.workspaceRootDirUri = workspaceFolder.uri.toString(true) 289 | return 290 | } 291 | // Guess that the first workspace is the chosen one. 292 | this.workspaceRootDirUri = firstDir.uri.toString(true) 293 | } 294 | 295 | /** 296 | * Finds the root file with respect to the current workspace and returns it. 297 | * The found root is also set to `rootFile`. 298 | */ 299 | async findRoot(): Promise { 300 | this.findWorkspace() 301 | const wsfolders = vscode.workspace.workspaceFolders?.map(e => e.uri.toString(true)) 302 | this.extension.logger.addLogMessage(`Current workspace folders: ${JSON.stringify(wsfolders)}`) 303 | this.extension.logger.addLogMessage(`Current workspaceRootDir: ${this.workspaceRootDirUri}`) 304 | this.localRootFile = undefined 305 | const findMethods = [ 306 | () => { 307 | if (!vscode.window.activeTextEditor) { 308 | return undefined 309 | } 310 | const regex = /^(?:%\s*!\s*T[Ee]X\sroot\s*=\s*(.*\.(?:tex|[jrsRS]nw|[rR]tex|jtexw))$)/m 311 | let content: string | undefined = vscode.window.activeTextEditor.document.getText() 312 | 313 | let result = content.match(regex) 314 | const fileStack: string[] = [] 315 | if (result) { 316 | let file = path.resolve(path.dirname(vscode.window.activeTextEditor.document.fileName), result[1]) 317 | content = fs.readFileSync(file).toString() 318 | if (content === undefined) { 319 | const msg = `Not found root file specified in the magic comment: ${file}` 320 | this.extension.logger.addLogMessage(msg) 321 | throw new Error(msg) 322 | } 323 | fileStack.push(file) 324 | this.extension.logger.addLogMessage(`Found root file by magic comment: ${file}`) 325 | 326 | result = content.match(regex) 327 | while (result) { 328 | file = path.resolve(path.dirname(file), result[1]) 329 | if (fileStack.includes(file)) { 330 | this.extension.logger.addLogMessage(`Looped root file by magic comment found: ${file}, stop here.`) 331 | return file 332 | } else { 333 | fileStack.push(file) 334 | this.extension.logger.addLogMessage(`Recursively found root file by magic comment: ${file}`) 335 | } 336 | 337 | content = fs.readFileSync(file).toString() 338 | if (content === undefined) { 339 | const msg = `Not found root file specified in the magic comment: ${file}` 340 | this.extension.logger.addLogMessage(msg) 341 | throw new Error(msg) 342 | 343 | } 344 | result = content.match(regex) 345 | } 346 | return file 347 | } 348 | return undefined 349 | }, 350 | () => this.findRootFromActive(), 351 | () => this.findRootInWorkspace() 352 | ] 353 | for (const method of findMethods) { 354 | const rootFile = await method() 355 | if (rootFile === undefined) { 356 | continue 357 | } 358 | if (this.rootFile !== rootFile) { 359 | this.extension.logger.addLogMessage(`Root file changed: from ${this.rootFile} to ${rootFile}`) 360 | this.extension.logger.addLogMessage('Start to find all dependencies.') 361 | this.rootFile = rootFile 362 | this.rootFileLanguageId = this.inferLanguageId(rootFile) 363 | this.extension.logger.addLogMessage(`Root file languageId: ${this.rootFileLanguageId}`) 364 | } else { 365 | this.extension.logger.addLogMessage(`Keep using the same root file: ${this.rootFile}`) 366 | } 367 | return rootFile 368 | } 369 | return undefined 370 | } 371 | 372 | private findRootFromActive(): string | undefined { 373 | if (!vscode.window.activeTextEditor) { 374 | return undefined 375 | } 376 | if (vscode.window.activeTextEditor.document.uri.scheme !== 'file') { 377 | this.extension.logger.addLogMessage(`The active document cannot be used as the root file: ${vscode.window.activeTextEditor.document.uri.toString(true)}`) 378 | return undefined 379 | } 380 | const regex = /\\begin{document}/m 381 | const content = utils.stripCommentsAndVerbatim(vscode.window.activeTextEditor.document.getText()) 382 | const result = content.match(regex) 383 | if (result) { 384 | const rootSubFile = this.finderUtils.findSubFiles(content) 385 | const file = vscode.window.activeTextEditor.document.fileName 386 | if (rootSubFile) { 387 | this.localRootFile = file 388 | return rootSubFile 389 | } else { 390 | this.extension.logger.addLogMessage(`Found root file from active editor: ${file}`) 391 | return file 392 | } 393 | } 394 | return undefined 395 | } 396 | 397 | private async findRootInWorkspace(): Promise { 398 | const regex = /\\begin{document}/m 399 | 400 | if (!this.workspaceRootDirUri) { 401 | return undefined 402 | } 403 | 404 | const configuration = vscode.workspace.getConfiguration('latex-workshop') 405 | const rootFilesIncludePatterns = configuration.get('latex.search.rootFiles.include') as string[] 406 | const rootFilesIncludeGlob = '{' + rootFilesIncludePatterns.join(',') + '}' 407 | const rootFilesExcludePatterns = configuration.get('latex.search.rootFiles.exclude') as string[] 408 | const rootFilesExcludeGlob = rootFilesExcludePatterns.length > 0 ? '{' + rootFilesExcludePatterns.join(',') + '}' : undefined 409 | try { 410 | const files = await vscode.workspace.findFiles(rootFilesIncludeGlob, rootFilesExcludeGlob) 411 | const candidates: string[] = [] 412 | for (const file of files) { 413 | if (file.scheme !== 'file') { 414 | this.extension.logger.addLogMessage(`Skip the file: ${file.toString(true)}`) 415 | continue 416 | } 417 | const flsChildren = this.getTeXChildrenFromFls(file.fsPath) 418 | if (vscode.window.activeTextEditor && flsChildren.includes(vscode.window.activeTextEditor.document.fileName)) { 419 | this.extension.logger.addLogMessage(`Found root file from '.fls': ${file.fsPath}`) 420 | return file.fsPath 421 | } 422 | const content = utils.stripCommentsAndVerbatim(fs.readFileSync(file.fsPath).toString()) 423 | const result = content.match(regex) 424 | if (result) { 425 | // Can be a root 426 | const children = this.getTeXChildren(file.fsPath, file.fsPath, [], content) 427 | if (vscode.window.activeTextEditor && children.includes(vscode.window.activeTextEditor.document.fileName)) { 428 | this.extension.logger.addLogMessage(`Found root file from parent: ${file.fsPath}`) 429 | return file.fsPath 430 | } 431 | // Not including the active file, yet can still be a root candidate 432 | candidates.push(file.fsPath) 433 | } 434 | } 435 | if (candidates.length > 0) { 436 | this.extension.logger.addLogMessage(`Found files that might be root, choose the first one: ${candidates}`) 437 | return candidates[0] 438 | } 439 | } catch (e) { 440 | this.extension.logger.addLogMessage(`Cannot find root file: ${e.message}`) 441 | } 442 | return undefined 443 | } 444 | 445 | private getTeXChildrenFromFls(texFile: string) { 446 | const flsFile = this.pathUtils.getFlsFilePath(texFile) 447 | if (flsFile === undefined) { 448 | return [] 449 | } 450 | const rootDir = path.dirname(texFile) 451 | const ioFiles = this.pathUtils.parseFlsContent(fs.readFileSync(flsFile).toString(), rootDir) 452 | return ioFiles.input 453 | } 454 | 455 | /** 456 | * Return the list of files (recursively) included in `file` 457 | * 458 | * @param file The file in which children are recursively computed 459 | * @param baseFile The file currently considered as the rootFile 460 | * @param children The list of already computed children 461 | * @param content The content of `file`. If undefined, it is read from disk 462 | */ 463 | private getTeXChildren(file: string, baseFile: string, children: string[], content?: string): string[] { 464 | if (content === undefined) { 465 | content = utils.stripCommentsAndVerbatim(fs.readFileSync(file).toString()) 466 | } 467 | 468 | // Update children of current file 469 | if (this.cachedContent[file] === undefined) { 470 | this.cachedContent[file] = {content, bibs: [], children: []} 471 | const pathRegexp = new PathRegExp() 472 | // eslint-disable-next-line no-constant-condition 473 | while (true) { 474 | const result: MatchPath | undefined = pathRegexp.exec(content) 475 | if (!result) { 476 | break 477 | } 478 | 479 | const inputFile = pathRegexp.parseInputFilePath(result, file, baseFile) 480 | 481 | if (!inputFile || 482 | !fs.existsSync(inputFile) || 483 | path.relative(inputFile, baseFile) === '') { 484 | continue 485 | } 486 | 487 | this.cachedContent[file].children.push({ 488 | index: result.index, 489 | file: inputFile 490 | }) 491 | } 492 | } 493 | 494 | this.cachedContent[file].children.forEach(child => { 495 | if (children.includes(child.file)) { 496 | // Already included 497 | return 498 | } 499 | children.push(child.file) 500 | this.getTeXChildren(child.file, baseFile, children) 501 | }) 502 | return children 503 | } 504 | } 505 | -------------------------------------------------------------------------------- /src/workshop/pathutils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import * as path from 'path' 3 | import * as fs from 'fs' 4 | import * as utils from './utils' 5 | 6 | import type {Extension} from '../main' 7 | 8 | export enum MatchType { 9 | Input, 10 | Child 11 | } 12 | 13 | export interface MatchPath { 14 | type: MatchType 15 | path: string 16 | directory: string 17 | matchedString: string 18 | index: number 19 | } 20 | 21 | export class PathRegExp { 22 | private readonly inputRegexp: RegExp 23 | private readonly childRegexp: RegExp 24 | 25 | constructor() { 26 | this.inputRegexp = /\\(?:input|InputIfFileExists|include|SweaveInput|subfile|loadglsentries|(?:(?:sub)?(?:import|inputfrom|includefrom)\*?{([^}]*)}))(?:\[[^[\]{}]*\])?{([^}]*)}/g 27 | this.childRegexp = /<<(?:[^,]*,)*\s*child='([^']*)'\s*(?:,[^,]*)*>>=/g 28 | } 29 | 30 | resetLastIndex() { 31 | this.inputRegexp.lastIndex = 0 32 | this.childRegexp.lastIndex = 0 33 | } 34 | 35 | /** 36 | * Return the matched input or child path. If there is no match, return undefined 37 | * 38 | * @param content the string to match the regex on 39 | */ 40 | exec(content: string): MatchPath | undefined { 41 | let result = this.inputRegexp.exec(content) 42 | if (result) { 43 | return { 44 | type: MatchType.Input, 45 | path: result[2], 46 | directory: result[1], 47 | matchedString: result[0], 48 | index: result.index 49 | } 50 | } 51 | result = this.childRegexp.exec(content) 52 | if (result) { 53 | return { 54 | type: MatchType.Child, 55 | path: result[1], 56 | directory: '', 57 | matchedString: result[0], 58 | index: result.index 59 | } 60 | } 61 | return undefined 62 | } 63 | /** 64 | * Compute the resolved file path from matches of this.inputReg or this.childReg 65 | * 66 | * @param regResult is the the result of this.inputReg.exec() or this.childReg.exec() 67 | * @param currentFile is the name of file in which the match has been obtained 68 | * @param rootFile 69 | */ 70 | parseInputFilePath(match: MatchPath, currentFile: string, rootFile: string): string | undefined { 71 | const texDirs = vscode.workspace.getConfiguration('latex-workshop').get('latex.texDirs') as string[] 72 | /* match of this.childReg */ 73 | if (match.type === MatchType.Child) { 74 | return utils.resolveFile([path.dirname(currentFile), path.dirname(rootFile), ...texDirs], match.path) 75 | } 76 | 77 | /* match of this.inputReg */ 78 | if (match.type === MatchType.Input) { 79 | if (match.matchedString.startsWith('\\subimport') || match.matchedString.startsWith('\\subinputfrom') || match.matchedString.startsWith('\\subincludefrom')) { 80 | return utils.resolveFile([path.dirname(currentFile)], path.join(match.directory, match.path)) 81 | } else if (match.matchedString.startsWith('\\import') || match.matchedString.startsWith('\\inputfrom') || match.matchedString.startsWith('\\includefrom')) { 82 | return utils.resolveFile([match.directory, path.join(path.dirname(rootFile), match.directory)], match.path) 83 | } else { 84 | return utils.resolveFile([path.dirname(currentFile), path.dirname(rootFile), ...texDirs], match.path) 85 | } 86 | } 87 | return undefined 88 | } 89 | 90 | } 91 | 92 | export class PathUtils { 93 | private readonly extension: Extension 94 | 95 | constructor(extension: Extension) { 96 | this.extension = extension 97 | } 98 | 99 | private getOutDir(texFile: string) { 100 | return this.extension.manager.getOutDir(texFile) 101 | } 102 | 103 | /** 104 | * Search for a `.fls` file associated to a tex file 105 | * @param texFile The path of LaTeX file 106 | * @return The path of the .fls file or undefined 107 | */ 108 | getFlsFilePath(texFile: string): string | undefined { 109 | const rootDir = path.dirname(texFile) 110 | const outDir = this.getOutDir(texFile) 111 | const baseName = path.parse(texFile).name 112 | const flsFile = path.resolve(rootDir, path.join(outDir, baseName + '.fls')) 113 | if (!fs.existsSync(flsFile)) { 114 | this.extension.logger.addLogMessage(`Cannot find fls file: ${flsFile}`) 115 | return undefined 116 | } 117 | this.extension.logger.addLogMessage(`Fls file found: ${flsFile}`) 118 | return flsFile 119 | } 120 | 121 | parseFlsContent(content: string, rootDir: string): {input: string[], output: string[]} { 122 | const inputFiles: Set = new Set() 123 | const outputFiles: Set = new Set() 124 | const regex = /^(?:(INPUT)\s*(.*))|(?:(OUTPUT)\s*(.*))$/gm 125 | // regex groups 126 | // #1: an INPUT entry --> #2 input file path 127 | // #3: an OUTPUT entry --> #4: output file path 128 | // eslint-disable-next-line no-constant-condition 129 | while (true) { 130 | const result = regex.exec(content) 131 | if (!result) { 132 | break 133 | } 134 | if (result[1]) { 135 | const inputFilePath = path.resolve(rootDir, result[2]) 136 | if (inputFilePath) { 137 | inputFiles.add(inputFilePath) 138 | } 139 | } else if (result[3]) { 140 | const outputFilePath = path.resolve(rootDir, result[4]) 141 | if (outputFilePath) { 142 | outputFiles.add(outputFilePath) 143 | } 144 | } 145 | } 146 | 147 | return {input: Array.from(inputFiles), output: Array.from(outputFiles)} 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/workshop/utils.ts: -------------------------------------------------------------------------------- 1 | // from James-Yu/LaTeX-Workshop. 2 | 3 | import * as vscode from 'vscode' 4 | import * as path from 'path' 5 | import * as fs from 'fs' 6 | 7 | import type {latexParser} from 'latex-utensils' 8 | 9 | 10 | export function sleep(ms: number) { 11 | return new Promise(resolve => setTimeout(resolve, ms)) 12 | } 13 | 14 | export function escapeHtml(s: string): string { 15 | return s.replace(/&/g, '&') 16 | .replace(/"/g, '"') 17 | .replace(//g, '>') 19 | } 20 | 21 | export function escapeRegExp(str: string) { 22 | return str.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&') 23 | } 24 | 25 | /** 26 | * Remove comments 27 | * 28 | * @param text A string in which comments get removed. 29 | * @return the input text with comments removed. 30 | * Note the number lines of the output matches the input 31 | */ 32 | export function stripComments(text: string): string { 33 | const reg = /(^|[^\\]|(?:(? { 51 | const len = Math.max(match.split('\n').length, 1) 52 | return '\n'.repeat(len - 1) 53 | }) 54 | } 55 | 56 | /** 57 | * Remove comments and verbatim content 58 | * Note the number lines of the output matches the input 59 | * 60 | * @param text A multiline string to be stripped 61 | * @return the input text with comments and verbatim content removed. 62 | */ 63 | export function stripCommentsAndVerbatim(text: string): string { 64 | let content = stripComments(text) 65 | content = content.replace(/\\verb\*?([^a-zA-Z0-9]).*?\1/g, '') 66 | const configuration = vscode.workspace.getConfiguration('latex-workshop') 67 | const verbatimEnvs = configuration.get('latex.verbatimEnvs') as string[] 68 | return stripEnvironments(content, verbatimEnvs) 69 | } 70 | 71 | /** 72 | * Trim leading and ending spaces on every line 73 | * See https://blog.stevenlevithan.com/archives/faster-trim-javascript for 74 | * possible ways of implementing trimming 75 | * 76 | * @param text a multiline string 77 | */ 78 | export function trimMultiLineString(text: string): string { 79 | return text.replace(/^\s\s*/gm, '').replace(/\s\s*$/gm, '') 80 | } 81 | 82 | /** 83 | * Find the longest substring containing balanced curly braces {...} 84 | * The string `s` can either start on the opening `{` or at the next character 85 | * 86 | * @param s A string to be searched. 87 | */ 88 | export function getLongestBalancedString(s: string): string { 89 | let nested = s[0] === '{' ? 0 : 1 90 | let i = 0 91 | for (i = 0; i < s.length; i++) { 92 | switch (s[i]) { 93 | case '{': 94 | nested++ 95 | break 96 | case '}': 97 | nested-- 98 | break 99 | case '\\': 100 | // skip an escaped character 101 | i++ 102 | break 103 | default: 104 | } 105 | if (nested === 0) { 106 | break 107 | } 108 | } 109 | return s.substring(s[0] === '{' ? 1 : 0, i) 110 | } 111 | 112 | 113 | export type CommandArgument = { 114 | arg: string // The argument we are looking for 115 | index: number // the starting position of the argument 116 | } 117 | 118 | /** 119 | * @param text a string starting with a command call 120 | * @param nth the index of the argument to return 121 | */ 122 | export function getNthArgument(text: string, nth: number): CommandArgument | undefined { 123 | let arg = '' 124 | let index = 0 // start of the nth argument 125 | let offset = 0 // current offset of the new text to consider 126 | for (let i=0; i string { 176 | return (arg: string) => { 177 | const configuration = vscode.workspace.getConfiguration('latex-workshop') 178 | const docker = configuration.get('docker.enabled') 179 | 180 | const workspaceFolder = vscode.workspace.workspaceFolders?.[0] 181 | const workspaceDir = workspaceFolder?.uri.fsPath.split(path.sep).join('/') || '' 182 | const rootFileParsed = path.parse(rootFile) 183 | const docfile = rootFileParsed.name 184 | const docfileExt = rootFileParsed.base 185 | const dirW32 = path.normalize(rootFileParsed.dir) 186 | const dir = dirW32.split(path.sep).join('/') 187 | const docW32 = path.join(dirW32, docfile) 188 | const doc = docW32.split(path.sep).join('/') 189 | const docExtW32 = path.join(dirW32, docfileExt) 190 | const docExt = docExtW32.split(path.sep).join('/') 191 | 192 | const expandPlaceHolders = (a: string): string => { 193 | return a.replace(/%DOC%/g, docker ? docfile : doc) 194 | .replace(/%DOC_W32%/g, docker ? docfile : docW32) 195 | .replace(/%DOC_EXT%/g, docker ? docfileExt : docExt) 196 | .replace(/%DOC_EXT_W32%/g, docker ? docfileExt : docExtW32) 197 | .replace(/%DOCFILE_EXT%/g, docfileExt) 198 | .replace(/%DOCFILE%/g, docfile) 199 | .replace(/%DIR%/g, docker ? './' : dir) 200 | .replace(/%DIR_W32%/g, docker ? './' : dirW32) 201 | .replace(/%TMPDIR%/g, tmpDir) 202 | .replace(/%WORKSPACE_FOLDER%/g, docker ? './' : workspaceDir) 203 | .replace(/%RELATIVE_DIR%/, docker ? './' : path.relative(workspaceDir, dir)) 204 | .replace(/%RELATIVE_DOC%/, docker ? docfile : path.relative(workspaceDir, doc)) 205 | 206 | } 207 | const outDirW32 = path.normalize(expandPlaceHolders(configuration.get('latex.outDir') as string)) 208 | const outDir = outDirW32.split(path.sep).join('/') 209 | return expandPlaceHolders(arg).replace(/%OUTDIR%/g, outDir).replace(/%OUTDIR_W32%/g, outDirW32) 210 | } 211 | } 212 | 213 | export type NewCommand = { 214 | kind: 'command' 215 | name: 'renewcommand|newcommand|providecommand|DeclareMathOperator|renewcommand*|newcommand*|providecommand*|DeclareMathOperator*' 216 | args: (latexParser.OptionalArg | latexParser.Group)[] 217 | location: latexParser.Location 218 | } 219 | 220 | export function isNewCommand(node: latexParser.Node | undefined): node is NewCommand { 221 | const regex = /^(renewcommand|newcommand|providecommand|DeclareMathOperator)(\*)?$/ 222 | if (!!node && node.kind === 'command' && node.name.match(regex)) { 223 | return true 224 | } 225 | return false 226 | } 227 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "lib": ["es2018"], 6 | "module": "commonjs", 7 | "noFallthroughCasesInSwitch": true, 8 | "noImplicitAny": true, 9 | "noImplicitReturns": true, 10 | "noImplicitThis": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "outDir": "out", 14 | "rootDir": ".", 15 | "sourceMap": true, 16 | "strictBindCallApply": true, 17 | "strictFunctionTypes": true, 18 | "strictNullChecks": true, 19 | "strictPropertyInitialization": true, 20 | "esModuleInterop": true, 21 | "target": "es2018", 22 | "baseUrl": "./", 23 | "typeRoots": ["./types", "./node_modules/@types"] 24 | }, 25 | "include": ["src/**/*.ts"] 26 | } 27 | --------------------------------------------------------------------------------