Full-featured .scad text editor w/ autocomplete
This commit is contained in:
parent
6486d698d4
commit
186049b215
14 changed files with 930 additions and 13 deletions
|
|
@ -3,6 +3,7 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.3.2",
|
||||
"@monaco-editor/react": "^4.4.6",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
|
||||
import React from 'react';
|
||||
import logo from '../public/logo.png';
|
||||
import SCADEditor from './Editor';
|
||||
import './App.css';
|
||||
|
||||
export function App() {
|
||||
|
|
@ -18,6 +20,8 @@ export function App() {
|
|||
>
|
||||
Learn OpenSCAD
|
||||
</a>
|
||||
<SCADEditor height="50vh" value="if (true) sphere(10); else cube();"/>
|
||||
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
18
src/BrowserFS.ts
Normal file
18
src/BrowserFS.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
|
||||
import { FS } from "./filesystem";
|
||||
|
||||
interface EmscriptenFS extends FS {
|
||||
|
||||
};
|
||||
|
||||
export let BrowserFS = (window as any)['BrowserFS'] as {
|
||||
BFSRequire: (name: string) => any,
|
||||
|
||||
install: (windowOrSelf: Window) => void,
|
||||
configure: (options: any, callback: (e?: any) => void) => void,
|
||||
|
||||
EmscriptenFS: {
|
||||
new(fs: FS, path: string, errnoCodes: object): EmscriptenFS
|
||||
}
|
||||
};
|
||||
36
src/Editor.tsx
Normal file
36
src/Editor.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
|
||||
import React from 'react';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||
const options: monaco.editor.IStandaloneEditorConstructionOptions = {
|
||||
lineNumbers: 'on',
|
||||
automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 12,
|
||||
language: 'openscad',
|
||||
wordWrap: 'on',
|
||||
wrappingStrategy: 'advanced',
|
||||
suggest: {
|
||||
// snippetsPreventQuickSuggestions: false,
|
||||
showStatusBar: true,
|
||||
preview: true,
|
||||
},
|
||||
codeLens: true,
|
||||
function SCADEditor({value, ...props}: {value: string, height: string}) {
|
||||
|
||||
// let editor: monaco.editor.IStandaloneCodeEditor;
|
||||
|
||||
// function editorDidMount(e) {
|
||||
// editor = e;
|
||||
// console.log('editorDidMount', monaco.languages.getLanguages(), editor);
|
||||
<Editor {...props} className="openscad-editor" defaultLanguage="openscad" value={value} options={options} />
|
||||
// onMount={editorDidMount} />
|
||||
);
|
||||
}
|
||||
|
||||
// export function createEditor() {
|
||||
// monaco.editor.create(document.getElementById('openscad-editor') as any, options);
|
||||
// }
|
||||
|
||||
export default SCADEditor;
|
||||
104
src/filesystem.ts
Normal file
104
src/filesystem.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// import FS from "browserfs/dist/node/core/FS";
|
||||
import { BrowserFS } from "./BrowserFS";
|
||||
import { zipArchives } from "./zip-archives";
|
||||
|
||||
export interface FS {
|
||||
readdir(path: string, cb: (err: any, files: string[]) => void): void;
|
||||
symlink(target: string, source: string): void;
|
||||
}
|
||||
|
||||
export type FSMounts = {
|
||||
[n: string]: {fs: string, options: {zipData: Buffer}}
|
||||
}
|
||||
|
||||
export type Symlinks = {[alias: string]: string};
|
||||
|
||||
export function readDirAsArray(fs: FS, path: string): Promise<string[] | undefined> {
|
||||
return new Promise((res, rej) => fs.readdir(path, (err, files) => err ? rej(err) : res(files)));
|
||||
}
|
||||
|
||||
export async function getBrowserFSLibrariesMounts(archiveNames: string[]) {
|
||||
const Buffer = BrowserFS.BFSRequire('buffer').Buffer;
|
||||
const fetchData = async (url: string) => (await fetch(url)).arrayBuffer();
|
||||
const results: [string, ArrayBuffer][] =
|
||||
await Promise.all(archiveNames.map(async (n: string) => [n, await fetchData(`./libraries/${n}.zip`)]));
|
||||
|
||||
const zipMounts: FSMounts = {};
|
||||
for (const [n, zipData] of results) {
|
||||
zipMounts[n] = {
|
||||
fs: "ZipFS",
|
||||
options: {
|
||||
zipData: Buffer.from(zipData)
|
||||
}
|
||||
}
|
||||
}
|
||||
return zipMounts;
|
||||
}
|
||||
|
||||
export async function symlinkLibraries(archiveNames: string[], fs: FS, prefix='/libraries', cwd='/tmp') {
|
||||
const createSymlink = async (target: string, source: string) => {
|
||||
// console.log('symlink', target, source);
|
||||
await fs.symlink(target, source);
|
||||
// await symlink(target, source);
|
||||
};
|
||||
|
||||
await Promise.all(archiveNames.map(n => (async () => {
|
||||
if (!(n in zipArchives)) throw `Archive named ${n} invalid (valid ones: ${Object.keys(zipArchives).join(', ')})`;
|
||||
const {symlinks} = (zipArchives)[n];
|
||||
if (symlinks) {
|
||||
for (const from in symlinks) {
|
||||
const to = symlinks[from];
|
||||
const target = to == '.' ? `${prefix}/${n}` : `${prefix}/${n}/${to}`;
|
||||
const source = from.startsWith('/') ? from : `${cwd}/${from}`;
|
||||
await createSymlink(target, source);
|
||||
}
|
||||
} else {
|
||||
await createSymlink(`${prefix}/${n}`, `${cwd}/${n}`);
|
||||
}
|
||||
})()));
|
||||
}
|
||||
|
||||
function configureAndInstallFS(windowOrSelf: Window, options: any) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
BrowserFS.install(windowOrSelf);
|
||||
try {
|
||||
BrowserFS.configure(options, function (e: any) {
|
||||
if (e) reject(e);
|
||||
else resolve(null);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function createEditorFS(workingDir='/home') {
|
||||
const archiveNames = Object.keys(zipArchives);
|
||||
const librariesMounts = await getBrowserFSLibrariesMounts(archiveNames);
|
||||
const allMounts: FSMounts = {};
|
||||
for (const n in librariesMounts) {
|
||||
allMounts[`${workingDir}/${n}`] = librariesMounts[n];
|
||||
}
|
||||
|
||||
await configureAndInstallFS(window, {
|
||||
fs: "OverlayFS",
|
||||
options: {
|
||||
readable: {
|
||||
fs: "MountableFileSystem",
|
||||
options: {
|
||||
...allMounts,
|
||||
}
|
||||
},
|
||||
writable: {
|
||||
fs: "InMemory"
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
var fs = BrowserFS.BFSRequire('fs');
|
||||
// const symlink = (target, source) => new Promise((res, rej) => fs.symlink(target, source, (err) => err ? rej(err) : res()));
|
||||
|
||||
// await setupLibraries(archiveNames, symlink, '/libraries', workingDir);
|
||||
return fs;
|
||||
}
|
||||
|
|
@ -1,13 +1,31 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import {App} from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import { createEditorFS } from './filesystem';
|
||||
import { registerOpenSCADLanguage } from './language/openscad-register-language';
|
||||
import { zipArchives } from './zip-archives';
|
||||
|
||||
(async () => {
|
||||
|
||||
const workingDir = '/home';
|
||||
const fs = await createEditorFS(workingDir);
|
||||
await registerOpenSCADLanguage(fs, workingDir, zipArchives);
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
})();
|
||||
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
|
|||
106
src/language/openscad-builtins.ts
Normal file
106
src/language/openscad-builtins.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
export default `
|
||||
$fa=undef;
|
||||
$fs=undef;
|
||||
$fn=undef;
|
||||
$t=undef;
|
||||
|
||||
$preview=undef;
|
||||
|
||||
// shows rotation
|
||||
$vpr=undef;
|
||||
// shows translation (i.e. won't be affected by rotate and zoom)
|
||||
$vpt=undef;
|
||||
// shows the FOV of the view [Note: Requires version 2021.01]
|
||||
$vpf=undef;
|
||||
// shows the camera distance [Note: Requires version 2015.03]
|
||||
$vpd=undef;
|
||||
|
||||
PI=undef;
|
||||
|
||||
function abs(x) = x;
|
||||
function acos(x) = x;
|
||||
function asin(x) = x;
|
||||
function atan(x) = x;
|
||||
function atan2(y, x) = x;
|
||||
function ceil(x) = x;
|
||||
function chr(x) = 0;
|
||||
function len(assignments) = $children;
|
||||
function let(x) = x;
|
||||
function ln(x) = x;
|
||||
function log(x) = x;
|
||||
function lookup(key, array) = x;
|
||||
function max(values) = x;
|
||||
function min(values) = x;
|
||||
function sqrt(x) = x;
|
||||
function tan(degrees) = x;
|
||||
function rands(min_value, max_value, value_count, seed_value=undef) = x;
|
||||
function search(match_value, string_or_vector, num_returns_per_match=1, index_col_num=0) = x;
|
||||
function ord(x) = x;
|
||||
function round(x) = x;
|
||||
function sign(x) = x;
|
||||
function sin(degrees) = x;
|
||||
function str(values) = x;
|
||||
function norm(x) = x;
|
||||
function pow(base, exponent) = x;
|
||||
function concat(values) = x;
|
||||
function cos(degrees) = x;
|
||||
function cross(a, b) = x;
|
||||
function floor(x) = x;
|
||||
function exp(x) = x;
|
||||
function chr(x) = x;
|
||||
function is_undef(x) = x;
|
||||
function is_list(x) = x;
|
||||
function is_num(x) = x;
|
||||
function is_bool(x) = x;
|
||||
function is_string(x) = x;
|
||||
function is_function(x) = x;
|
||||
|
||||
function version() = '';
|
||||
function version_num() = 0;
|
||||
|
||||
$parent_modules=0;
|
||||
module parent_module(n) {}
|
||||
|
||||
module children() {}
|
||||
|
||||
module render(convexity=undef) {}
|
||||
module surface(file, center=false, invert=false, convexity=undef) {}
|
||||
|
||||
function assert(condition, message=undef) = children();
|
||||
module assert(condition, message=undef) children();
|
||||
|
||||
module cube(size, center=false) {}
|
||||
module sphere(r, d=undef, $fa, $fs, $fn) {}
|
||||
module cylinder(h, r, r1=undef, r2, d, d1, d2, center=false, $fa, $fs, $fn) {}
|
||||
module polyhedron(points, faces, convexity=undef) {}
|
||||
|
||||
module square(size, center=false) {}
|
||||
module circle(r, d=undef, $fa, $fs, $fn) {}
|
||||
module polygon(points, paths, convexity=undef) {}
|
||||
module linear_extrude(height, center=false, twist=undef, slices=undef, scale=undef, convexity=undef) children();
|
||||
module rotate_extrude(degrees, convexity=undef, $fa, $fs, $fn) children();
|
||||
|
||||
module scale(v) children();
|
||||
module resize(newsize, auto=false) children();
|
||||
module rotate(a, v=undef) children();
|
||||
module translate(v) children();
|
||||
module mirror(v) children();
|
||||
module multmatrix(m) children();
|
||||
|
||||
module color(c, alpha) children();
|
||||
|
||||
module offset(r, delta=undef, chamfer) children();
|
||||
|
||||
module minkowski() children();
|
||||
module union() children();
|
||||
module difference() children();
|
||||
module intersection() children();
|
||||
module hull() children();
|
||||
|
||||
module children() {}
|
||||
|
||||
// module for(i=undef) children();
|
||||
|
||||
module import(file, convexity=undef, $fn, $fa, $fs) {}
|
||||
`;
|
||||
288
src/language/openscad-completions.ts
Normal file
288
src/language/openscad-completions.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
import FS from 'browserfs/dist/node/core/FS';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||
import { FSMounts, readDirAsArray, Symlinks } from '../filesystem';
|
||||
import { ParsedFile, ParsedFunctionoidDef, parseOpenSCAD, stripComments } from './openscad-pseudoparser';
|
||||
import builtinSignatures from './openscad-builtins'
|
||||
import { mapObject } from '../utils';
|
||||
import { ZipArchives } from '../zip-archives';
|
||||
import openscadLanguage from './openscad-language';
|
||||
|
||||
function makeFunctionoidSuggestion(name: string, mod: ParsedFunctionoidDef) {
|
||||
const argSnippets: string[] = [];
|
||||
const namedArgs: string[] = [];
|
||||
let collectingPosArgs = true;
|
||||
let i = 0;
|
||||
for (const param of mod.params ?? []) {
|
||||
if (collectingPosArgs) {
|
||||
if (param.defaultValue != null) {
|
||||
collectingPosArgs = false;
|
||||
} else {
|
||||
//argSnippets.push(`${param.name}=${'${' + (i + 1) + ':' + param.name + '}'}`);
|
||||
argSnippets.push(`${param.name.replaceAll('$', '\\$')}=${'${' + (++i) + ':' + param.name + '}'}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
namedArgs.push(param.name);
|
||||
}
|
||||
if (namedArgs.length) {
|
||||
argSnippets.push(`${'${' + (++i) + ':' + namedArgs.join('|') + '=}'}`);
|
||||
}
|
||||
let insertText = `${name.replaceAll('$', '\\$')}(${argSnippets.join(', ')})`;
|
||||
if (mod.referencesChildren !== null) {
|
||||
insertText += mod.referencesChildren ? ' ${' + (++i) + ':children}' : ';';
|
||||
}
|
||||
return {
|
||||
label: mod.signature,//`${name}(${(mod.params ?? []).join(', ')})`,
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet
|
||||
};
|
||||
}
|
||||
|
||||
const builtinCompletions = [
|
||||
...[true, false].map(v => ({
|
||||
label: `${v}`,
|
||||
kind: monaco.languages.CompletionItemKind.Value,
|
||||
insertText: `${v}`,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet
|
||||
})),
|
||||
...openscadLanguage.language.keywords.map(v => ({
|
||||
label: v,
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: v,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet
|
||||
}))
|
||||
];
|
||||
|
||||
const keywordSnippets = [
|
||||
'for(${1:variable}=[${2:start}:${3:end}) ${4:body}',
|
||||
'for(${1:variable}=[${2:start}:${3:increment}:${4:end}) ${5:body}',
|
||||
'if (${1:condition}) {\n\t$0\n} else {\n\t\n}'
|
||||
];
|
||||
|
||||
function cleanupVariables(snippet: string) {
|
||||
return snippet
|
||||
.replaceAll(/\$\{\d+:([$\w]+)\}/g, '$1')
|
||||
.replaceAll(/\$\d+/g, '')
|
||||
.replaceAll(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// https://microsoft.github.io/monaco-editor/playground.html#extending-language-services-custom-languages
|
||||
export async function buildOpenSCADCompletionItemProvider(fs: FS, workingDir: string, zipArchives: ZipArchives) {
|
||||
|
||||
const parsedFiles: {[path: string]: Promise<ParsedFile>} = {};
|
||||
const toAbsolutePath = (path: string) => path.startsWith('/') ? path : `${workingDir}/${path}`;
|
||||
|
||||
const allSymlinks: Symlinks = {};
|
||||
for (const n of Object.keys(zipArchives)) {
|
||||
if (n == 'fonts') {
|
||||
continue;
|
||||
}
|
||||
const { symlinks } = zipArchives[n];
|
||||
for (const s in symlinks) {
|
||||
allSymlinks[s] = `${n}/${symlinks[s]}`;
|
||||
}
|
||||
}
|
||||
async function readFile(path: string) {
|
||||
if (path in allSymlinks) {
|
||||
path = allSymlinks[path];
|
||||
}
|
||||
path = toAbsolutePath(path);
|
||||
try {
|
||||
const bytes = await fs.readFileSync(path);
|
||||
const src = new TextDecoder("utf-8").decode(bytes);
|
||||
return src;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
const builtinsPath = '<builtins>';
|
||||
let builtinsDefs: ParsedFile;
|
||||
|
||||
function getParsed(path: string, src: string, {skipPrivates, addBuiltins}: {skipPrivates: boolean, addBuiltins: boolean}) {
|
||||
return parsedFiles[path] ??= new Promise(async (res, rej) => {
|
||||
if (src == null) {
|
||||
src = await readFile(path);
|
||||
}
|
||||
const result: ParsedFile = {
|
||||
functions: {},
|
||||
modules: {},
|
||||
vars: [],
|
||||
includes: [],
|
||||
uses: [],
|
||||
}
|
||||
|
||||
const mergeDefinitions = (isUse: boolean, defs: ParsedFile) => {
|
||||
result.functions = {...result.functions, ...defs.functions }
|
||||
result.modules = {...result.modules, ...defs.modules }
|
||||
if (!isUse) {
|
||||
result.vars = [...result.vars, ...defs.vars]
|
||||
}
|
||||
};
|
||||
const dir = (path.split('/').slice(0, -1).join('/') || '.') + '/';
|
||||
|
||||
const handleInclude = async (isUse: boolean, otherPath: string) => {
|
||||
for (const path of [`${dir}/${otherPath}`, otherPath]) {
|
||||
try {
|
||||
const otherSrc = await readFile(otherPath);
|
||||
const sub = await getParsed(otherPath, otherSrc, {skipPrivates: true, addBuiltins: false});
|
||||
mergeDefinitions(isUse, sub);
|
||||
} catch (e) {
|
||||
// console.warn(path, e);
|
||||
}
|
||||
}
|
||||
console.error('Failed to find ', otherPath, '(context imported in ', path, ')');
|
||||
};
|
||||
|
||||
if (addBuiltins && path != builtinsPath) {
|
||||
mergeDefinitions(false, builtinsDefs);
|
||||
}
|
||||
|
||||
const ownDefs = parseOpenSCAD(path, src, skipPrivates);
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
...(ownDefs.uses ?? []).map(p => [p, true] as [string, boolean]),
|
||||
...(ownDefs.includes ?? []).map(p => [p, false] as [string, boolean])
|
||||
].map(([otherPath, isUse]) => handleInclude(isUse, otherPath)));
|
||||
|
||||
mergeDefinitions(false, ownDefs);
|
||||
|
||||
res(result);
|
||||
});
|
||||
}
|
||||
|
||||
builtinsDefs = await getParsed(builtinsPath, builtinSignatures, {skipPrivates: false, addBuiltins: false});
|
||||
|
||||
return {
|
||||
triggerCharacters: ["<", "/"], //, "\n"],
|
||||
//provideCompletionItems: (async (model, position, context, token) => {
|
||||
provideCompletionItems: ((async (model: monaco.editor.ITextModel, position: monaco.Position, context: monaco.languages.CompletionContext, token: monaco.CancellationToken) => {
|
||||
try {
|
||||
const {word} = model.getWordUntilPosition(position);
|
||||
const offset = model.getOffsetAt(position);
|
||||
const text = model.getValue();
|
||||
let previous = text.substring(0, offset);
|
||||
let i = previous.lastIndexOf('\n');
|
||||
previous = previous.substring(i + 1);
|
||||
|
||||
const includeMatch = /\b(include|use)\s*<([^<>\n"]*)$/.exec(previous);
|
||||
if (includeMatch) {
|
||||
const prefix = includeMatch[2];
|
||||
let folder, filePrefix, folderPrefix;
|
||||
const i = prefix.lastIndexOf('/');
|
||||
if (i < 0) {
|
||||
folderPrefix = '';
|
||||
filePrefix = prefix;
|
||||
} else {
|
||||
folderPrefix = prefix.substring(0, i);
|
||||
filePrefix = prefix.substring(i + 1);
|
||||
}
|
||||
folder = workingDir + (folderPrefix == '' ? '' : '/' + folderPrefix);
|
||||
let files = folderPrefix == '' ? [...Object.keys(allSymlinks)] : [];
|
||||
try {
|
||||
files = [...(await readDirAsArray(fs, folder) ?? []), ...files];
|
||||
// console.log('readDir', folder, files);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
const suggestions = [];
|
||||
for (const file of files) {
|
||||
if (filePrefix != '' && !file.startsWith(filePrefix)) {
|
||||
continue;
|
||||
}
|
||||
if (/^(LICENSE.*|fonts)$/.test(file)) {
|
||||
continue;
|
||||
}
|
||||
if (folderPrefix == '' && (file in zipArchives) && zipArchives[file].symlinks) {
|
||||
continue;
|
||||
}
|
||||
const isFolder = !file.endsWith('.scad');
|
||||
const completion = file + (isFolder ? '' : '>\n'); // don't append '/' as it's a useful trigger char
|
||||
|
||||
console.log(JSON.stringify({
|
||||
prefix,
|
||||
folder,
|
||||
filePrefix,
|
||||
folderPrefix,
|
||||
// files,
|
||||
completion,
|
||||
file,
|
||||
}, null, 2));
|
||||
|
||||
suggestions.push({
|
||||
label: file,
|
||||
kind: isFolder ? monaco.languages.CompletionItemKind.Folder : monaco.languages.CompletionItemKind.File,
|
||||
insertText: completion
|
||||
});
|
||||
}
|
||||
suggestions.sort();
|
||||
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
const inputFile = workingDir + "/foo.scad";
|
||||
delete parsedFiles[inputFile];
|
||||
const parsed = await getParsed(inputFile, text, {skipPrivates: false, addBuiltins: true});
|
||||
// console.log("PARSED", JSON.stringify(parsed, null, 2));
|
||||
|
||||
type CompletionItem = monaco.languages.CompletionItem & {
|
||||
range?: monaco.IRange
|
||||
}
|
||||
|
||||
const previousWithoutComments = stripComments(previous);
|
||||
// console.log('previousWithoutComments', previousWithoutComments);
|
||||
const statementMatch = /(^|.*?[{});]|>\s*\n)\s*([$\w]*)$/m.exec(previousWithoutComments);
|
||||
if (statementMatch) {
|
||||
const start = statementMatch[1];
|
||||
const suggestions: CompletionItem[] = [
|
||||
...builtinCompletions,
|
||||
...mapObject(
|
||||
parsed.modules ?? {},
|
||||
(name, mod) => makeFunctionoidSuggestion(name, mod),
|
||||
name => name.indexOf(word) >= 0),
|
||||
...((parsed.vars ?? []).filter(name => name.indexOf(word) >= 0).map(name => ({
|
||||
label: name,
|
||||
kind: monaco.languages.CompletionItemKind.Variable,
|
||||
insertText: name.replaceAll('$', '\\$'),
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet
|
||||
}))),
|
||||
...keywordSnippets.map(snippet => ({
|
||||
label: cleanupVariables(snippet).replaceAll(/ body/g, ''),
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: snippet,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet
|
||||
})),
|
||||
// ...getStatementSuggestions().filter(s => start == '' || s.insertText.indexOf(start) >= 0)
|
||||
];
|
||||
suggestions.sort((a, b) => a.insertText.indexOf(start) - b.insertText.indexOf(start));
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
const allWithoutComments = stripComments(text);
|
||||
|
||||
const named: [string, CompletionItem][] = [
|
||||
...mapObject(parsed.functions ?? {},
|
||||
(name, mod) => [name, makeFunctionoidSuggestion(name, mod)],
|
||||
name => name.indexOf(word) >= 0)
|
||||
];
|
||||
named.sort(([a], [b]) => a.indexOf(word) - b.indexOf(word));
|
||||
// const suggestions = names.map(name => ({
|
||||
// label: name,
|
||||
// kind: monaco.languages.CompletionItemKind.Constant,
|
||||
// insertText: name
|
||||
// }));
|
||||
|
||||
const suggestions = named.map(([n, s]) => s as any as monaco.languages.CompletionItem);
|
||||
return { suggestions };
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);//, (e as any).stackTrace);
|
||||
return { suggestions: [] };
|
||||
}
|
||||
}) as any),
|
||||
} as monaco.languages.CompletionItemProvider;
|
||||
}
|
||||
186
src/language/openscad-language.ts
Normal file
186
src/language/openscad-language.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
import FS from 'browserfs/dist/node/core/FS';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||
|
||||
const builtInFunctionNames = [
|
||||
'abs',
|
||||
'acos', 'asin', 'atan', 'atan2', 'ceil',
|
||||
'len', 'let', 'ln', 'log',
|
||||
'lookup', 'max', 'min', 'sqrt', 'tan', 'rands',
|
||||
'search', 'sign', 'sin', 'str', 'norm', 'pow',
|
||||
'concat', 'cos', 'cross', 'floor', 'exp',
|
||||
'chr',
|
||||
];
|
||||
const builtInModuleNames = [
|
||||
'children',
|
||||
'circle', 'color', 'cube', 'cylinder',
|
||||
'diameter', 'difference', 'echo', 'extrude',
|
||||
'for', 'function', 'hull', 'if', 'include',
|
||||
'intersection_for', 'intersection', 'linear', 'minkowski', 'mirror', 'module', 'multmatrix',
|
||||
'offset', 'polyhedron', 'projection', 'radius',
|
||||
'render', 'resize', 'rotate', 'round', 'scale',
|
||||
'sphere', 'square', 'surface', 'translate',
|
||||
'union', 'use', 'value', 'version',
|
||||
// 'center', 'width', 'height',
|
||||
];
|
||||
const builtInVarNames = [
|
||||
'false', 'true', 'PI', 'undef', '$children',
|
||||
'$fa', '$fn', '$fs', '$t', '$vpd', '$vpr', '$vpt',
|
||||
]
|
||||
|
||||
var conf: monaco.languages.LanguageConfiguration = {
|
||||
|
||||
colorizedBracketPairs: [['{', '}'], ['(', ')'], ['[', ']']],
|
||||
|
||||
wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,
|
||||
comments: {
|
||||
lineComment: "//",
|
||||
blockComment: ["/*", "*/"]
|
||||
},
|
||||
brackets: [
|
||||
["{", "}"],
|
||||
["[", "]"],
|
||||
["(", ")"]
|
||||
],
|
||||
onEnterRules: [
|
||||
{
|
||||
beforeText: /^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/,
|
||||
afterText: /^\s*\*\/$/,
|
||||
action: {
|
||||
indentAction: monaco.languages.IndentAction.IndentOutdent,
|
||||
appendText: " * "
|
||||
}
|
||||
},
|
||||
{
|
||||
beforeText: /^\s*\/\*\*(?!\/)([^\*]|\*(?!\/))*$/,
|
||||
action: {
|
||||
indentAction: monaco.languages.IndentAction.None,
|
||||
appendText: " * "
|
||||
}
|
||||
},
|
||||
{
|
||||
beforeText: /^(\t|(\ \ ))*\ \*(\ ([^\*]|\*(?!\/))*)?$/,
|
||||
action: {
|
||||
indentAction: monaco.languages.IndentAction.None,
|
||||
appendText: "* "
|
||||
}
|
||||
},
|
||||
{
|
||||
beforeText: /^(\t|(\ \ ))*\ \*\/\s*$/,
|
||||
action: {
|
||||
indentAction: monaco.languages.IndentAction.None,
|
||||
removeText: 1
|
||||
}
|
||||
}
|
||||
],
|
||||
autoClosingPairs: [
|
||||
{ open: "{", close: "}" },
|
||||
{ open: "[", close: "]" },
|
||||
{ open: "(", close: ")" },
|
||||
{ open: '"', close: '"', notIn: ["string"] },
|
||||
{ open: "'", close: "'", notIn: ["string", "comment"] },
|
||||
{ open: "`", close: "`", notIn: ["string", "comment"] },
|
||||
{ open: "/**", close: " */", notIn: ["string"] }
|
||||
],
|
||||
folding: {
|
||||
markers: {
|
||||
start: new RegExp("^\\s*//\\s*#?region\\b"),
|
||||
end: new RegExp("^\\s*//\\s*#?endregion\\b")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var language: monaco.languages.IMonarchLanguage = {
|
||||
defaultToken: "invalid",
|
||||
tokenPostfix: ".js",
|
||||
keywords: [...builtInFunctionNames, ...builtInModuleNames, ...builtInVarNames, 'each'],
|
||||
typeKeywords: [],
|
||||
operators: [
|
||||
"<=",
|
||||
">=",
|
||||
"==",
|
||||
"!=",
|
||||
"=>",
|
||||
"+",
|
||||
"-",
|
||||
"*",
|
||||
"/",
|
||||
"%",
|
||||
"<<",
|
||||
">>",
|
||||
">>>",
|
||||
"&",
|
||||
"|",
|
||||
"^",
|
||||
"!",
|
||||
"&&",
|
||||
"||",
|
||||
"?",
|
||||
":",
|
||||
"=",
|
||||
],
|
||||
symbols: /[=><!~?:&|+\-*\/\^%]+/,
|
||||
escapes: /\\[abfnrtv\\"']/,
|
||||
digits: /\d+/,
|
||||
tokenizer: {
|
||||
root: [[/[{}]/, "delimiter.bracket"], { include: "common" }],
|
||||
common: [
|
||||
[
|
||||
/[a-z_$][\w$]*/,
|
||||
{
|
||||
cases: {
|
||||
"@keywords": "keyword",
|
||||
"@default": "identifier"
|
||||
}
|
||||
}
|
||||
],
|
||||
[/[A-Z][\w\$]*/, "type.identifier"],
|
||||
{ include: "@whitespace" },
|
||||
[/[()\[\]]/, "@brackets"],
|
||||
[/[<>](?!@symbols)/, "@brackets"],
|
||||
[/!(?=([^=]|$))/, "delimiter"],
|
||||
[
|
||||
/@symbols/,
|
||||
{
|
||||
cases: {
|
||||
"@operators": "delimiter",
|
||||
"@default": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
[/(@digits)[eE]([\-+]?(@digits))?/, "number.float"],
|
||||
[/(@digits)\.(@digits)([eE][\-+]?(@digits))?/, "number.float"],
|
||||
[/(@digits)n?/, "number"],
|
||||
[/[;,.]/, "delimiter"],
|
||||
[/"([^"\\]|\\.)*$/, "string.invalid"],
|
||||
[/'([^'\\]|\\.)*$/, "string.invalid"],
|
||||
[/"/, "string", "@string_double"],
|
||||
],
|
||||
whitespace: [
|
||||
[/[ \t\r\n]+/, ""],
|
||||
[/\/\*/, "comment", "@comment"],
|
||||
[/\/\/.*$/, "comment"]
|
||||
],
|
||||
comment: [
|
||||
[/[^\/*]+/, "comment"],
|
||||
[/\*\//, "comment", "@pop"],
|
||||
[/[\/*]/, "comment"]
|
||||
],
|
||||
string_double: [
|
||||
[/[^\\"]+/, "string"],
|
||||
[/@escapes/, "string.escape"],
|
||||
[/\\./, "string.escape.invalid"],
|
||||
[/"/, "string", "@pop"]
|
||||
],
|
||||
bracketCounting: [
|
||||
[/\{/, "delimiter.bracket", "@bracketCounting"],
|
||||
[/\}/, "delimiter.bracket", "@pop"],
|
||||
{ include: "common" }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
conf,
|
||||
language,
|
||||
}
|
||||
68
src/language/openscad-pseudoparser.ts
Normal file
68
src/language/openscad-pseudoparser.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
export type ParsedFunctionoidDef = {
|
||||
path: string,
|
||||
name: string,
|
||||
params?: {
|
||||
name: string,
|
||||
defaultValue: string,
|
||||
}[],
|
||||
signature: string,
|
||||
referencesChildren: boolean | null,
|
||||
};
|
||||
export type ParsedFunctionoidDefs = {[name: string]: ParsedFunctionoidDef};
|
||||
|
||||
export type ParsedFile = {
|
||||
functions: ParsedFunctionoidDefs,
|
||||
modules: ParsedFunctionoidDefs,
|
||||
vars: string[],
|
||||
includes: string[],
|
||||
uses: string[],
|
||||
};
|
||||
|
||||
export const stripComments = (src: string) => src.replaceAll(/\/\*(.|[\s\S])*?\*\/|\/\/.*?$/gm, '');
|
||||
|
||||
export function parseOpenSCAD(path: string, src: string, skipPrivates: boolean): ParsedFile {
|
||||
const withoutComments = stripComments(src);
|
||||
const vars = [];
|
||||
const functions: ParsedFunctionoidDefs = {};
|
||||
const modules: ParsedFunctionoidDefs = {};
|
||||
const includes: string[] = [];
|
||||
const uses: string[] = [];
|
||||
for (const m of withoutComments.matchAll(/(use|include)\s*<([^>]+)>/g)) {
|
||||
(m[1] == 'use' ? uses : includes).push(m[2]);
|
||||
}
|
||||
for (const m of withoutComments.matchAll(/(?:^|[{};])\s*([$\w]+)\s*=/g)) {
|
||||
vars.push(m[1]);
|
||||
}
|
||||
for (const m of withoutComments.matchAll(/(function|module)\s+([$\w]+)\s*\(([^)]*)\)(?:\s*(?:=\s*)?(\{\}|[^{}]+?;))?/gm)) {
|
||||
const type = m[1];
|
||||
const name = m[2];
|
||||
if (skipPrivates && name.startsWith('_')) {
|
||||
continue;
|
||||
}
|
||||
const paramsStr = m[3];
|
||||
const optBody = m[4];
|
||||
const params = [];
|
||||
if (/^(\s*([$\w]+(\s*=[^,()[]+)?(\s*,\s*[$\w]+(\s*=[^,()[]+)?)*)?\s*)$/m.test(paramsStr)) {
|
||||
for (const paramStr of paramsStr.split(',')) {
|
||||
const am = /^\s*([$\w]+)(?:\s*=([^,()[]+))?\s*$/.exec(paramStr);
|
||||
if (am) {
|
||||
const paramName = am[1];
|
||||
const defaultValue = am[2];
|
||||
params.push({
|
||||
name: paramName,
|
||||
defaultValue
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
(type == 'function' ? functions : modules)[name] = {
|
||||
path,
|
||||
name,
|
||||
signature: `${name}(${paramsStr.replaceAll(/[\s]+/gm, ' ').replaceAll(/\b | \b/g, '')})`,
|
||||
params,
|
||||
referencesChildren: optBody != null ? (optBody.indexOf('children()') >= 0) : null,
|
||||
};
|
||||
}
|
||||
return {vars, functions, modules, includes, uses};
|
||||
}
|
||||
26
src/language/openscad-register-language.ts
Normal file
26
src/language/openscad-register-language.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
|
||||
import loader from '@monaco-editor/loader';
|
||||
import { ZipArchives } from '../zip-archives';
|
||||
import { buildOpenSCADCompletionItemProvider } from './openscad-completions';
|
||||
import openscadLanguage from './openscad-language';
|
||||
// import jsLanguage from 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution'
|
||||
|
||||
// https://microsoft.github.io/monaco-editor/playground.html#extending-language-services-custom-languages
|
||||
export async function registerOpenSCADLanguage(fs: any, workingDir: string, zipArchives: ZipArchives) {
|
||||
const monaco = await loader.init();
|
||||
|
||||
monaco.languages.register({
|
||||
id: 'openscad',
|
||||
extensions: ['.scad'],
|
||||
mimetypes: ["text/openscad"],
|
||||
});
|
||||
|
||||
//const [inheritLanguage] = monaco.languages.getLanguages().filter(l => l.id === 'javascript');
|
||||
const { conf, language } = openscadLanguage;
|
||||
monaco.languages.setLanguageConfiguration('openscad', conf);
|
||||
monaco.languages.setMonarchTokensProvider('openscad', language);
|
||||
|
||||
monaco.languages.registerCompletionItemProvider('openscad',
|
||||
await buildOpenSCADCompletionItemProvider(fs, workingDir, zipArchives));
|
||||
}
|
||||
12
src/utils.ts
Normal file
12
src/utils.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
|
||||
export function mapObject(o: any, f: (key: string, value: any) => any, ifPred: (key: string) => boolean) {
|
||||
const ret = [];
|
||||
for (const key of Object.keys(o)) {
|
||||
if (ifPred && !ifPred(key)) {
|
||||
continue;
|
||||
}
|
||||
ret.push(f(key, o[key]));
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
50
src/zip-archives.ts
Normal file
50
src/zip-archives.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
|
||||
import { Symlinks } from "./filesystem";
|
||||
|
||||
export type ZipArchives = {
|
||||
[name: string]: {
|
||||
symlinks?: Symlinks
|
||||
}
|
||||
};
|
||||
|
||||
export const zipArchives: ZipArchives = {
|
||||
'fonts': {},
|
||||
// @openscad
|
||||
'MCAD': {},
|
||||
// @revarbat
|
||||
'BOSL': {},
|
||||
'BOSL2': {
|
||||
// "includes": {
|
||||
// "BOSL2/std.scad": "The Belfry OpenScad Library, v2.0. An OpenSCAD library of shapes, masks, and manipulators to make working with OpenSCAD easier. BETA"
|
||||
// }
|
||||
},
|
||||
// @nophead
|
||||
'NopSCADlib': {},
|
||||
// @thehans
|
||||
'FunctionalOpenSCAD': {},
|
||||
'funcutils': {},
|
||||
// @colyer
|
||||
'smooth-prim': {
|
||||
symlinks: {'smooth_prim.scad': 'smooth_prim.scad'},
|
||||
},
|
||||
'closepoints': {
|
||||
symlinks: {'closepoints.scad': 'closepoints.scad'},
|
||||
},
|
||||
'plot-function': {
|
||||
symlinks: {'plot_function.scad': 'plot_function.scad'},
|
||||
},
|
||||
// 'threads': {},
|
||||
// @sofian
|
||||
'openscad-tray': {
|
||||
symlinks: {'tray.scad': 'tray.scad'},
|
||||
},
|
||||
// @mrWheel
|
||||
'YAPP_Box': {},
|
||||
// @Cantareus
|
||||
'Stemfie_OpenSCAD': {},
|
||||
// @UBaer21
|
||||
'UB.scad': {
|
||||
symlinks: {"ub.scad": "libraries/ub.scad"},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"target": "ES2021",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext",
|
||||
"ES2021",
|
||||
"WebWorker",
|
||||
],
|
||||
"outDir": "js",
|
||||
|
|
|
|||
Loading…
Reference in a new issue