diff --git a/package.json b/package.json index 2309445..54c5e9c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index 6e5c2ab..2bf2210 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 + + ); diff --git a/src/BrowserFS.ts b/src/BrowserFS.ts new file mode 100644 index 0000000..7924e58 --- /dev/null +++ b/src/BrowserFS.ts @@ -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 + } +}; diff --git a/src/Editor.tsx b/src/Editor.tsx new file mode 100644 index 0000000..cc8944e --- /dev/null +++ b/src/Editor.tsx @@ -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); + + // onMount={editorDidMount} /> + ); +} + +// export function createEditor() { +// monaco.editor.create(document.getElementById('openscad-editor') as any, options); +// } + +export default SCADEditor; diff --git a/src/filesystem.ts b/src/filesystem.ts new file mode 100644 index 0000000..a2c1a22 --- /dev/null +++ b/src/filesystem.ts @@ -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 { + 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; +} diff --git a/src/index.tsx b/src/index.tsx index 1fd12b7..ae195a1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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( + + + + ); + + // 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( - - - -); diff --git a/src/language/openscad-builtins.ts b/src/language/openscad-builtins.ts new file mode 100644 index 0000000..601af8c --- /dev/null +++ b/src/language/openscad-builtins.ts @@ -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) {} +`; \ No newline at end of file diff --git a/src/language/openscad-completions.ts b/src/language/openscad-completions.ts new file mode 100644 index 0000000..10221da --- /dev/null +++ b/src/language/openscad-completions.ts @@ -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} = {}; + 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 = ''; + 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; +} diff --git a/src/language/openscad-language.ts b/src/language/openscad-language.ts new file mode 100644 index 0000000..bc30d92 --- /dev/null +++ b/src/language/openscad-language.ts @@ -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: /[=>](?!@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, +} diff --git a/src/language/openscad-pseudoparser.ts b/src/language/openscad-pseudoparser.ts new file mode 100644 index 0000000..10af1f6 --- /dev/null +++ b/src/language/openscad-pseudoparser.ts @@ -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}; +} diff --git a/src/language/openscad-register-language.ts b/src/language/openscad-register-language.ts new file mode 100644 index 0000000..79ac6f2 --- /dev/null +++ b/src/language/openscad-register-language.ts @@ -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)); +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..91bb486 --- /dev/null +++ b/src/utils.ts @@ -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; +} diff --git a/src/zip-archives.ts b/src/zip-archives.ts new file mode 100644 index 0000000..d2e0dc7 --- /dev/null +++ b/src/zip-archives.ts @@ -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"}, + }, +}; diff --git a/tsconfig.json b/tsconfig.json index 37b9b60..330f000 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2021", "lib": [ "dom", "dom.iterable", - "esnext", + "ES2021", "WebWorker", ], "outDir": "js",