Full-featured .scad text editor w/ autocomplete

This commit is contained in:
ochafik 2023-03-24 04:04:05 +00:00
commit 186049b215
14 changed files with 930 additions and 13 deletions

View file

@ -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",

View file

@ -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
View 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
View 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
View 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;
}

View file

@ -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>
);

View 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) {}
`;

View 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;
}

View 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,
}

View 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};
}

View 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
View 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
View 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"},
},
};

View file

@ -1,10 +1,10 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ES2021",
"lib": [
"dom",
"dom.iterable",
"esnext",
"ES2021",
"WebWorker",
],
"outDir": "js",