diff --git a/.vscode/launch.json b/.vscode/launch.json index 8384213..fdc9165 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,13 +1,16 @@ -// A launch configuration that launches the extension inside a new window +// A launch configuration that launches only the extension under development. { - "version": "0.1.0", + "version": "0.2.0", "configurations": [ { "name": "Launch Extension", "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", - "args": ["--extensionDevelopmentPath=${workspaceRoot}" ] + "args": [ + "--disable-extensions", + "--extensionDevelopmentPath=${workspaceFolder}" + ] } ] -} \ No newline at end of file +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 168d414..5668abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,4 +5,24 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [0.0.1] - 2017-06-23 -- Initial release \ No newline at end of file +- Initial release + +## [0.1.0] - 2026-03-18 +- Added highlighting for Quickbase formula-query functions such as `GetRecord()`, `GetRecords()`, `GetFieldValues()`, `Size()`, and `SumValues()` +- Added support for Quickbase API query-string operators including `EX`, `GT`, `HAS`, `WC`, and related operators +- Expanded formula syntax coverage for modern variable types and common operators like `=`, `!=`, `<>`, and `&` +- Updated README and extension metadata to reflect formula and query-string support + +## [Unreleased] +- Added live Quickbase diagnostics for duplicate variables, invalid variable names, malformed query strings, regex-pattern literal rules, structural expression errors, and baseline type/function validation +- Query field IDs such as `'6'` or `6` inside API query blocks now use a variable-style scope instead of a field-name scope +- Variable declarations now scope `var`, the declared data type, and the variable name independently for more consistent coloring, with `var` using a dedicated keyword scope +- Bracketed field and table references such as `[_DBID_PROJECTS]` now use the same scope as variable names +- Added PowerShell/Pester grammar regression tests for query field IDs and variable declarations +- Added sample-based grammar snapshot tests that run against representative `.quickbase` fixtures +- Added hover documentation for formula-query functions, query operators, variable declarations, variable types, and bracketed references +- Added grammar and validation support for bare query values such as `today` and `_curuser_` + + + + diff --git a/README.md b/README.md index 3c443ea..4643f71 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,63 @@ -# Syntax Highlighter for Quickbase Formulas +# Quickbase Formula & Query Tools -Highlights database fields, variables, operators, and built in functions. +VS Code language support for Quickbase formulas and query expressions. This extension adds syntax highlighting, snippets, hover documentation, and live validation for `.quickbase` files so formula work is easier to read, write, and review. -![screenshot](https://raw.githubusercontent.com/jdklub/vscode-quickbase-formula/master/images/screenshot.png) +![screenshot](reference/example_image.png) ## Requirements -Files must have a .quickbase extension +Use the `.quickbase` file extension for Quickbase formulas, formula-query expressions, and standalone query text. -## Known Issues +## Features -Very little testing has been done. +- Syntax highlighting for Quickbase formulas, comments, operators, variables, variable declarations, bracketed field and table references, and modern Quickbase functions +- Query-aware highlighting for API query strings and formula-query expressions, including operators like `EX`, `GT`, `TV`, `WC`, logical joiners like `AND` and `OR`, and special values such as `'today'` and `'_curuser_'` +- Snippets for Quickbase functions, variables, and common authoring patterns +- Live diagnostics for structural formula errors, malformed query expressions, duplicate variables, invalid variable names, unknown variable types, unknown functions, regex-pattern issues, and baseline argument/type mismatches +- Hover documentation for formula-query functions, query operators, variable declarations, variable types, bracketed references, and special query values +- Grammar, hover, and validation regression coverage through PowerShell/Pester tests and committed tokenization snapshots -## Release Notes +## Examples -### 0.0.1 +```quickbase +var RecordList openProjects = GetRecords("{'6'.EX.'In Progress'}", [_DBID_PROJECTS]); +var TextList owners = GetFieldValues($openProjects, 8); -Initial version. Good luck! \ No newline at end of file +Join($owners, "; ") +``` + +```quickbase +var Text todayQuery = "{'13'.EX.'today'}AND{'20'.TV.'_curuser_'}"; +``` + +## Current Scope + +The extension is intentionally lightweight and editor-focused. It does not execute formulas or connect to Quickbase metadata, so field-level inference and exhaustive function semantics are still conservative. + +## Development + +Reference notes gathered from current Quickbase documentation live in `docs/quickbase-documentation-notes.md`. + +Run the full test suite from PowerShell: + +```powershell +powershell -ExecutionPolicy Bypass -Command "Invoke-Pester .\tests" +``` + +Refresh committed tokenization snapshots after an intentional grammar change: + +```powershell +powershell -ExecutionPolicy Bypass -File .\tests\Invoke-QuickbaseTokenization.ps1 update +``` + +## Attribution + +This project builds on the original `vscode-quickbase-formula` extension and its contributors. + +- Justin Klubnik created the original extension, published the initial Quickbase syntax support, and remains the named copyright holder in `LICENSE.txt` +- Chris Pliakas contributed later grammar, snippet, scope, and branding updates that expanded the extension's Quickbase coverage +- Derek Banker led the current modernization work, including updated formula and query support, diagnostics, hover documentation, and automated regression testing + +## License + +MIT-style license. See `LICENSE.txt`. diff --git a/docs/quickbase-documentation-notes.md b/docs/quickbase-documentation-notes.md new file mode 100644 index 0000000..446159a --- /dev/null +++ b/docs/quickbase-documentation-notes.md @@ -0,0 +1,140 @@ +# Quickbase Documentation Notes + +Updated: 2026-03-19 + +Purpose: capture the current official Quickbase formula and query documentation we should use as the source of truth for syntax, validation, and future completion/hover work in this extension. + +## Official sources reviewed + +- Formula components: https://help.quickbase.com/docs/formula-components +- Formula variables: https://help.quickbase.com/docs/formula-variables +- Creating and using application variables: https://help.quickbase.com/variables +- What are formula queries?: https://help.quickbase.com/docs/what-are-formula-queries +- Build queries for your formulas: https://help.quickbase.com/docs/build-queries-for-your-formulas +- Formula queries: field types, type conversions, and variables: https://help.quickbase.com/docs/formula-queries-field-types-type-conversions-and-variables +- Formula queries and performance: https://help.quickbase.com/docs/formula-queries-and-performance +- Find field, record IDs, and table aliases: https://help.quickbase.com/docs/find-field-record-ids-and-table-aliases +- API_DoQuery: https://help.quickbase.com/docs/api-doquery +- Regex in formulas: https://help.quickbase.com/hc/en-us/articles/32800503697044-Regex-in-formulas +- Quickbase December 2022 release notes: https://help.quickbase.com/docs/quickbase-december-2022-release-notes +- Quickbase January 2025 release notes: https://help.quickbase.com/docs/quickbase-january-2025-release-notes +- Quickbase April 2025 release notes: https://help.quickbase.com/hc/en-us/articles/36386041144852-Quickbase-April-2025-Release-Notes +- Quickbase May 2025 release notes: https://help.quickbase.com/docs/quickbase-may-2025-release-notes +- Secure links: https://help.quickbase.com/hc/en-us/articles/29353956195220-Secure-links + +## Local seed files reviewed + +- `C:\CFS - Derek\Reference Documents\QuickBase Formula Documentation.csv` +- `C:\CFS - Derek\Refrence Documents\QuickBase Formula Examples.csv` + +The documentation CSV still looks useful as a seed list of functions and signatures, but it appears to predate several newer formula-query additions and newer text/query helpers. + +## Stable syntax rules to support + +### Formula variables + +- Declaration format: `var = ;` +- Official declared types currently documented: + - `bool` + - `number` + - `text` + - `textlist` + - `date` + - `datetime` + - `duration` + - `timeofday` + - `workdate` + - `user` + - `recordlist` +- Variables are referenced with `$name`. +- Variable names must contain letters only. No numbers, spaces, or special characters are documented. +- `var recordlist` is specifically called out for formula queries. + +### Field references and application variables + +- Field references use square brackets, for example `[Manager]`. +- Application variables also use bracket syntax, for example `[Project Start Date]`. +- Application variables are always treated as text unless they are converted with a formula function such as `ToDate()`. + +### Formula queries + +- Official formula-query functions: + - `GetRecord()` + - `GetRecordByUniqueField()` + - `GetRecords()` + - `GetFieldValues()` + - `SumValues()` + - `Size()` +- Query strings in formula-query functions should be enclosed in double quotes. +- A query block uses `{field.operator.value}` structure. +- Single quotes around the field ID are optional in formula queries. +- Comparison operators must be uppercase. +- Multiple query blocks can be combined with `AND` or `OR`. +- To inject a field value into a query string, concatenate with `&`, for example: + - `"{'5'.EX.'" & [Manager name] & "'}"` +- For user-field queries, prefer `TV` and wrap the field reference in `UserToID()` or `UserToEmail()`. +- When querying another table, Quickbase recommends using the table alias instead of the raw DBID. + +## Query operators confirmed from API_DoQuery + +- `CT`: contains +- `XCT`: does not contain +- `WC`: wildcard search with `*` and `?` +- `HAS`: contains values in List - User or Multi-select Text fields +- `XHAS`: does not contain values in List - User or Multi-select Text fields +- `EX`: equals +- `TV`: true value comparison, including user fields and relationship keys +- `XTV`: not equal using true-value comparison +- `XEX`: not equal +- `SW`: starts with +- `XSW`: does not start with +- `BF`: before +- `OBF`: on or before +- `AF`: after +- `OAF`: on or after +- `IR`: is during a relative date range +- `XIR`: is not during a relative date range +- `LT`: less than +- `LTE`: less than or equal +- `GT`: greater than +- `GTE`: greater than or equal + +## Type and structure guidance from the docs + +- `GetFieldValues()` can be used directly in Formula - Multi-select text fields. +- `SumValues()` and `Size()` can be used directly in Formula - Numeric fields. +- To use formula-query results in other field types, Quickbase expects either: + - a conversion function such as `ToText()`, or + - additional formula logic around the query result. +- `recordlist` values are intermediate values. They are normally consumed by `GetFieldValues()`, `SumValues()`, `Size()`, or converted to text. +- Regex support is documented as: + - `RegexExtract()` + - `RegexReplace()` + - `RegexMatch()` +- Regex patterns must be written directly in the formula call and cannot come from a field or variable. + +## Performance guidance worth validating against later + +- Prefer table relationships when a simple relationship or summary field already solves the problem. +- Avoid filtering, sorting, or grouping reports on formula-query fields when possible. +- Avoid making formula-query fields searchable unless needed. +- Start queries with the most selective comparison when possible. +- Prefer exact matches over broader operators like contains or wildcard matching when performance matters. + +## Gaps between the local CSV and current docs + +The local CSV is still a strong starting point, but the official docs confirm newer capabilities that should drive future validator coverage: + +- `GetRecordByUniqueField()` is officially documented in the Quickbase December 2022 release notes and formula-query docs, but it is not present in the local documentation CSV. +- `RegexExtract()`, `RegexReplace()`, and `RegexMatch()` are officially documented in current help content and January 2025 release notes, but they are not present in the local documentation CSV. +- `Join()` and `Median()` are called out in the April 2025 release notes and should be treated as current syntax. +- Aggregate functions on formula-query results such as `Avg()`, `Min()`, `Max()`, and `Median()` are called out in the May 2025 release notes. +- `SHA256()` and `ToUnixTime()` are evidenced in the Secure links help article and should be treated as live formula syntax. +- `GetAccessKey()` appears in the snippet catalog, but I did not confirm a current official help-center page for it during this pass, so it should stay marked as "needs source confirmation." + +## Useful follow-up work + +- Normalize the local CSV into structured JSON so validator signatures can be generated instead of hand-maintained. +- Add a curated metadata layer for formula-query functions and query operators from the official docs. +- Tighten variable-name validation to match the documented "letters only" rule. +- Add diagnostics for unsupported use of formula-query return types in incompatible field contexts once field metadata is available. diff --git a/extension.js b/extension.js new file mode 100644 index 0000000..41808d9 --- /dev/null +++ b/extension.js @@ -0,0 +1,141 @@ +var vscode = require('vscode'); +var quickbaseHoverService = require('./lib/quickbase-hover-service'); +var quickbaseLanguageService = require('./lib/quickbase-language-service'); +var quickbaseReference = require('./lib/quickbase-reference'); +var snippets = require('./snippets/snippets.json'); + +function activate(context) { + var functionCatalog = quickbaseLanguageService.buildFunctionCatalog(snippets, quickbaseReference); + var diagnostics = vscode.languages.createDiagnosticCollection('quickbase'); + var timers = Object.create(null); + + function isQuickbaseDocument(document) { + return document && (document.languageId === 'quickbase' || /\.quickbase$/i.test(document.fileName || '')); + } + + function toSeverity(level) { + if (level === 'error') { + return vscode.DiagnosticSeverity.Error; + } + if (level === 'information') { + return vscode.DiagnosticSeverity.Information; + } + if (level === 'hint') { + return vscode.DiagnosticSeverity.Hint; + } + return vscode.DiagnosticSeverity.Warning; + } + + function createHover(document, position) { + var hoverData; + var range; + var contents = []; + var markdown; + + if (!isQuickbaseDocument(document)) { + return null; + } + + hoverData = quickbaseHoverService.getHoverData(document.getText(), document.offsetAt(position), functionCatalog, quickbaseReference); + if (!hoverData) { + return null; + } + + range = new vscode.Range(document.positionAt(hoverData.start), document.positionAt(hoverData.end)); + + markdown = new vscode.MarkdownString(); + if (hoverData.signatures && hoverData.signatures.length) { + markdown.appendCodeblock(hoverData.signatures.join('\n'), 'quickbase'); + } else { + markdown.appendCodeblock(hoverData.label, 'quickbase'); + } + contents.push(markdown); + + if (hoverData.summary) { + contents.push(new vscode.MarkdownString(hoverData.summary)); + } + + if (hoverData.notes && hoverData.notes.length) { + contents.push(new vscode.MarkdownString('- ' + hoverData.notes.join('\n- '))); + } + + if (hoverData.docsUrl) { + markdown = new vscode.MarkdownString('[Quickbase docs](' + hoverData.docsUrl + ')'); + markdown.isTrusted = true; + contents.push(markdown); + } + + return new vscode.Hover(contents, range); + } + + function validateDocument(document) { + var validation; + var items; + var index; + var item; + var range; + var diagnostic; + + if (!isQuickbaseDocument(document)) { + return; + } + + validation = quickbaseLanguageService.validateText(document.getText(), functionCatalog); + items = []; + + for (index = 0; index < validation.diagnostics.length; index += 1) { + item = validation.diagnostics[index]; + range = new vscode.Range(document.positionAt(item.start), document.positionAt(item.end)); + diagnostic = new vscode.Diagnostic(range, item.message, toSeverity(item.severity)); + diagnostic.code = item.code; + diagnostic.source = 'quickbase'; + items.push(diagnostic); + } + + diagnostics.set(document.uri, items); + } + + function scheduleValidation(document) { + var key; + + if (!isQuickbaseDocument(document)) { + return; + } + + key = document.uri.toString(); + if (timers[key]) { + clearTimeout(timers[key]); + } + + timers[key] = setTimeout(function () { + delete timers[key]; + validateDocument(document); + }, 150); + } + + context.subscriptions.push(diagnostics); + context.subscriptions.push(vscode.languages.registerHoverProvider('quickbase', { + provideHover: createHover + })); + context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(validateDocument)); + context.subscriptions.push(vscode.workspace.onDidChangeTextDocument(function (event) { + scheduleValidation(event.document); + })); + context.subscriptions.push(vscode.workspace.onDidCloseTextDocument(function (document) { + diagnostics.delete(document.uri); + })); + + if (vscode.window && vscode.window.visibleTextEditors) { + vscode.window.visibleTextEditors.forEach(function (editor) { + validateDocument(editor.document); + }); + } +} + +function deactivate() { +} + +module.exports = { + activate: activate, + deactivate: deactivate +}; diff --git a/language-configuration.json b/language-configuration.json index f74cbe1..bfb1725 100644 --- a/language-configuration.json +++ b/language-configuration.json @@ -5,8 +5,11 @@ // symbols used as brackets "brackets": [ ["(", ")"], - ["{", "}"], - ["[", "]"] + ["{", "}"] + ], + "colorizedBracketPairs": [ + ["(", ")"], + ["{", "}"] ], // symbols that are auto closed when typing "autoClosingPairs": [ @@ -24,4 +27,4 @@ ["\"", "\""], ["'", "'"] ] -} \ No newline at end of file +} diff --git a/lib/quickbase-hover-service.js b/lib/quickbase-hover-service.js new file mode 100644 index 0000000..7197efd --- /dev/null +++ b/lib/quickbase-hover-service.js @@ -0,0 +1,452 @@ +(function (root, factory) { + var api = factory(); + + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } + + if (root) { + root.QuickbaseHoverService = api; + } +}(this, function () { + function normalizeName(value) { + return String(value || '').toLowerCase(); + } + + function displayType(typeName) { + var normalized = normalizeName(typeName); + var labels = { + bool: 'Boolean', + date: 'Date', + datetime: 'DateTime', + duration: 'Duration', + field: 'Field', + number: 'Number', + recordlist: 'RecordList', + table: 'Table', + text: 'Text', + textlist: 'TextList', + timeofday: 'TimeOfDay', + user: 'User', + userlist: 'UserList', + workdate: 'WorkDate' + }; + + return labels[normalized] || typeName || 'Value'; + } + + function inRange(offset, start, end) { + return offset >= start && offset < end; + } + + function cloneArray(values) { + var result = []; + var index; + + if (!values || !values.length) { + return result; + } + + for (index = 0; index < values.length; index += 1) { + result.push(values[index]); + } + + return result; + } + + function buildDeclarations(text) { + var declarations = []; + var pattern = /\bvar\s+([A-Za-z]+)\s+([A-Za-z_][A-Za-z0-9_]*)/g; + var match; + var fullText; + var fullStart; + var typeText; + var nameText; + var typeStart; + var nameStart; + + while ((match = pattern.exec(text))) { + fullText = match[0]; + fullStart = match.index; + typeText = match[1]; + nameText = match[2]; + typeStart = fullStart + fullText.indexOf(typeText); + nameStart = fullStart + fullText.lastIndexOf(nameText); + + declarations.push({ + keywordStart: fullStart, + keywordEnd: fullStart + 3, + type: typeText, + typeStart: typeStart, + typeEnd: typeStart + typeText.length, + name: nameText, + nameStart: nameStart, + nameEnd: nameStart + nameText.length + }); + } + + return declarations; + } + + function findDeclarationByOffset(declarations, offset) { + var index; + var declaration; + + for (index = 0; index < declarations.length; index += 1) { + declaration = declarations[index]; + if (inRange(offset, declaration.keywordStart, declaration.keywordEnd)) { + return { + declaration: declaration, + part: 'keyword' + }; + } + if (inRange(offset, declaration.typeStart, declaration.typeEnd)) { + return { + declaration: declaration, + part: 'type' + }; + } + if (inRange(offset, declaration.nameStart, declaration.nameEnd)) { + return { + declaration: declaration, + part: 'name' + }; + } + } + + return null; + } + + function findDeclarationByName(declarations, name, offset) { + var index; + var declaration = null; + + for (index = 0; index < declarations.length; index += 1) { + if (declarations[index].name === name && declarations[index].nameStart <= offset) { + declaration = declarations[index]; + } + } + + return declaration; + } + + function findVariableReference(text, offset) { + var pattern = /\$[A-Za-z_][A-Za-z0-9_]*/g; + var match; + + while ((match = pattern.exec(text))) { + if (inRange(offset, match.index, match.index + match[0].length)) { + return { + text: match[0], + name: match[0].slice(1), + start: match.index, + end: match.index + match[0].length + }; + } + } + + return null; + } + + function findBracketReference(text, offset) { + var pattern = /\[[^\]\r\n]+\]/g; + var match; + + while ((match = pattern.exec(text))) { + if (inRange(offset, match.index, match.index + match[0].length)) { + return { + text: match[0], + start: match.index, + end: match.index + match[0].length + }; + } + } + + return null; + } + + function findFunctionToken(text, offset, functionCatalog) { + var pattern = /\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/g; + var match; + var name; + var start; + + while ((match = pattern.exec(text))) { + name = match[1]; + start = match.index; + if (!functionCatalog[normalizeName(name)]) { + continue; + } + if (inRange(offset, start, start + name.length)) { + return { + name: name, + start: start, + end: start + name.length + }; + } + } + + return null; + } + + function findQueryOperator(text, offset, referenceData) { + var pattern = /\.([A-Za-z]{2,4})\./g; + var match; + var operator; + var start; + + while ((match = pattern.exec(text))) { + operator = String(match[1] || '').toUpperCase(); + if (!referenceData.getQueryOperatorMetadata(operator)) { + continue; + } + start = match.index + 1; + if (inRange(offset, start, start + match[1].length)) { + return { + operator: operator, + rawText: match[1], + start: start, + end: start + match[1].length + }; + } + } + + return null; + } + + + function findQuerySpecialValue(text, offset, referenceData) { + var pattern = /\{\s*('(?:[^']+)'|\d+)\s*\.([A-Za-z]{2,4})\s*\.('(?:today|_curuser_|-?\d+\s+days\s+ago)'|today|_curuser_)\s*\}/gi; + var match; + var value; + var relativeStart; + var start; + var metadata; + + while ((match = pattern.exec(text))) { + value = match[3]; + metadata = referenceData.getQuerySpecialValueMetadata(value); + if (!metadata) { + continue; + } + relativeStart = match[0].lastIndexOf(value); + start = match.index + relativeStart; + if (inRange(offset, start, start + value.length)) { + return { + value: value, + start: start, + end: start + value.length, + metadata: metadata + }; + } + } + + return null; + } + + function findQueryField(text, offset) { + var pattern = /\{\s*('(?:[^']+)'|\d+)\s*\./g; + var match; + var value; + var relativeStart; + var start; + + while ((match = pattern.exec(text))) { + value = match[1]; + relativeStart = match[0].indexOf(value); + start = match.index + relativeStart; + if (inRange(offset, start, start + value.length)) { + return { + value: value, + start: start, + end: start + value.length + }; + } + } + + return null; + } + + function signatureToString(name, signature) { + var parts = []; + var index; + + for (index = 0; index < signature.params.length; index += 1) { + parts.push(displayType(signature.params[index].type)); + } + + if (signature.variadic) { + if (parts.length) { + parts[parts.length - 1] = parts[parts.length - 1] + ', ...'; + } else { + parts.push(displayType(signature.variadicType) + ', ...'); + } + } + + return name + '(' + parts.join(', ') + ')'; + } + + function buildFunctionHover(entry) { + var signatures = []; + var index; + + for (index = 0; index < entry.signatures.length; index += 1) { + signatures.push(signatureToString(entry.name, entry.signatures[index])); + } + + return { + kind: 'function', + label: entry.name, + summary: entry.summary || 'Quickbase formula function.', + signatures: signatures, + notes: cloneArray(entry.notes), + docsUrl: entry.docsUrl || 'https://help.quickbase.com/docs/formula-components' + }; + } + + function getHoverData(text, offset, functionCatalog, referenceData) { + var declarations = buildDeclarations(text || ''); + var match; + var metadata; + var declaration; + var entry; + var variableReference; + var bracketReference; + var functionToken; + var queryOperator; + var querySpecialValue; + var queryField; + + referenceData = referenceData || {}; + + queryOperator = findQueryOperator(text, offset, referenceData); + if (queryOperator) { + metadata = referenceData.getQueryOperatorMetadata(queryOperator.operator); + return { + start: queryOperator.start, + end: queryOperator.end, + kind: 'queryOperator', + label: queryOperator.operator, + summary: metadata.summary, + notes: cloneArray(metadata.notes), + docsUrl: metadata.docsUrl + }; + } + + + querySpecialValue = findQuerySpecialValue(text, offset, referenceData); + if (querySpecialValue) { + metadata = querySpecialValue.metadata || referenceData.getQuerySpecialValueMetadata(querySpecialValue.value); + return { + start: querySpecialValue.start, + end: querySpecialValue.end, + kind: 'querySpecialValue', + label: querySpecialValue.value, + summary: metadata.summary, + notes: cloneArray(metadata.notes), + docsUrl: metadata.docsUrl + }; + } + queryField = findQueryField(text, offset); + if (queryField) { + metadata = referenceData.getSyntaxMetadata('queryField'); + return { + start: queryField.start, + end: queryField.end, + kind: 'queryField', + label: queryField.value, + summary: metadata.summary, + docsUrl: metadata.docsUrl, + notes: [] + }; + } + + variableReference = findVariableReference(text, offset); + if (variableReference) { + declaration = findDeclarationByName(declarations, variableReference.name, variableReference.start); + metadata = referenceData.getSyntaxMetadata('variableReference'); + return { + start: variableReference.start, + end: variableReference.end, + kind: 'variableReference', + label: variableReference.text, + summary: declaration ? ('Declared as ' + displayType(declaration.type) + '.') : metadata.summary, + notes: [], + docsUrl: metadata.docsUrl + }; + } + + match = findDeclarationByOffset(declarations, offset); + if (match) { + if (match.part === 'keyword') { + metadata = referenceData.getSyntaxMetadata('variableKeyword'); + return { + start: match.declaration.keywordStart, + end: match.declaration.keywordEnd, + kind: 'variableKeyword', + label: metadata.label, + summary: metadata.summary, + notes: [], + docsUrl: metadata.docsUrl + }; + } + if (match.part === 'type') { + metadata = referenceData.getVariableTypeMetadata(match.declaration.type); + if (metadata) { + return { + start: match.declaration.typeStart, + end: match.declaration.typeEnd, + kind: 'variableType', + label: metadata.label, + summary: metadata.summary, + notes: [], + docsUrl: metadata.docsUrl + }; + } + } + if (match.part === 'name') { + metadata = referenceData.getSyntaxMetadata('variableReference'); + return { + start: match.declaration.nameStart, + end: match.declaration.nameEnd, + kind: 'variableDeclaration', + label: '$' + match.declaration.name, + summary: 'Declared as ' + displayType(match.declaration.type) + '.', + notes: [], + docsUrl: metadata.docsUrl + }; + } + } + + functionToken = findFunctionToken(text, offset, functionCatalog || {}); + if (functionToken) { + entry = functionCatalog[normalizeName(functionToken.name)]; + if (entry) { + metadata = buildFunctionHover(entry); + metadata.start = functionToken.start; + metadata.end = functionToken.end; + return metadata; + } + } + + bracketReference = findBracketReference(text, offset); + if (bracketReference) { + metadata = referenceData.getSyntaxMetadata('bracketReference'); + return { + start: bracketReference.start, + end: bracketReference.end, + kind: 'bracketReference', + label: bracketReference.text, + summary: metadata.summary, + notes: [], + docsUrl: metadata.docsUrl + }; + } + + return null; + } + + return { + getHoverData: getHoverData + }; +})); diff --git a/lib/quickbase-language-service.js b/lib/quickbase-language-service.js new file mode 100644 index 0000000..56d907c --- /dev/null +++ b/lib/quickbase-language-service.js @@ -0,0 +1,2088 @@ +(function (root, factory) { + var api = factory(); + + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } + + if (root) { + root.QuickbaseLanguageService = api; + } +}(this, function () { + var QUERY_OPERATORS = { + CT: true, + XCT: true, + WC: true, + HAS: true, + XHAS: true, + EX: true, + TV: true, + XTV: true, + XEX: true, + SW: true, + XSW: true, + BF: true, + OBF: true, + AF: true, + OAF: true, + IR: true, + XIR: true, + LT: true, + LTE: true, + GT: true, + GTE: true + }; + + var VARIABLE_TYPES = { + bool: true, + number: true, + text: true, + textlist: true, + date: true, + datetime: true, + duration: true, + timeofday: true, + workdate: true, + user: true, + userlist: true, + recordlist: true + }; + + var MANUAL_SIGNATURES = { + 'if': { + special: 'if' + }, + 'case': { + special: 'case' + }, + 'includes': { + signatures: [ + { + params: [{ type: 'userlist' }, { type: 'userlist' }], + minArgs: 2, + maxArgs: Infinity, + variadic: true, + variadicType: 'userlist' + } + ], + returnStrategy: 'bool' + } + }; + + var RETURN_STRATEGIES = { + abs: 'same-as-first', + adjustmonth: 'date', + adjustyear: 'date', + appid: 'text', + average: 'same-as-first', + avg: 'number', + base64decode: 'text', + base64encode: 'text', + begins: 'bool', + ceil: 'same-as-first', + contains: 'bool', + count: 'number', + date: 'date', + day: 'number', + dayofweek: 'number', + dayofyear: 'number', + days: 'duration', + dbid: 'text', + ends: 'bool', + exp: 'number', + firstdayofmonth: 'date', + firstdayofperiod: 'date', + firstdayofweek: 'date', + firstdayofyear: 'date', + floor: 'same-as-first', + frac: 'number', + getaccesskey: 'text', + getfieldproperty: 'text', + getfieldvalues: 'textlist', + getrecord: 'recordlist', + getrecordbyuniquefield: 'recordlist', + getrecords: 'recordlist', + htmltotext: 'text', + hour: 'number', + hours: 'duration', + includes: 'bool', + int: 'number', + isleapday: 'bool', + isleapyear: 'bool', + isnull: 'bool', + isuseremail: 'bool', + isweekday: 'bool', + join: 'text', + lastdayofmonth: 'date', + lastdayofperiod: 'date', + lastdayofweek: 'date', + lastdayofyear: 'date', + left: 'text', + length: 'number', + list: 'text', + ln: 'number', + log: 'number', + lower: 'text', + max: 'same-as-first', + median: 'number', + mid: 'text', + min: 'same-as-first', + minute: 'number', + minutes: 'duration', + mod: 'same-as-first', + month: 'number', + msecond: 'number', + mseconds: 'duration', + nameofmonth: 'text', + nextdayofweek: 'date', + notleft: 'text', + notright: 'text', + now: 'datetime', + nz: 'same-as-first', + padleft: 'text', + padright: 'text', + part: 'text', + prevdayofweek: 'date', + pv: 'number', + qb32decode: 'text', + qb32encode: 'text', + regexextract: 'text', + regexmatch: 'bool', + regexreplace: 'text', + rem: 'number', + right: 'text', + round: 'number', + searchandreplace: 'text', + second: 'number', + seconds: 'duration', + sha256: 'text', + size: 'number', + split: 'textlist', + sqrt: 'number', + sum: 'same-as-first', + sumvalues: 'number', + toboolean: 'bool', + todate: 'date', + toformattedtext: 'text', + tohours: 'number', + tominutes: 'number', + tomseconds: 'number', + tonumber: 'number', + toseconds: 'number', + totext: 'text', + totimeofday: 'timeofday', + totimestamp: 'datetime', + tounixtime: 'number', + touser: 'user', + touserlist: 'userlist', + toweekdayn: 'date', + toweekdayp: 'date', + toweeks: 'number', + toworkdate: 'workdate', + trim: 'text', + upper: 'text', + urlencode: 'text', + urlroot: 'text', + user: 'user', + userlisttoemails: 'textlist', + userlisttoids: 'textlist', + userlisttonames: 'textlist', + userroles: 'textlist', + usertoemail: 'text', + usertoid: 'number', + usertoname: 'text', + weekdayadd: 'date', + weekdaysub: 'date', + weekofyear: 'number', + weeks: 'duration', + workdayadd: 'workdate', + year: 'number' + }; + + function trim(value) { + return String(value || '').replace(/^\s+|\s+$/g, ''); + } + + function startsWith(value, search) { + return String(value).slice(0, search.length) === search; + } + + function isDigit(character) { + return character >= '0' && character <= '9'; + } + + function isIdentifierStart(character) { + return !!character && /[A-Za-z_]/.test(character); + } + + function isIdentifierPart(character) { + return !!character && /[A-Za-z0-9_]/.test(character); + } + + function createLineStarts(text) { + var lineStarts = [0]; + var index; + + for (index = 0; index < text.length; index += 1) { + if (text.charAt(index) === '\n') { + lineStarts.push(index + 1); + } + } + + return lineStarts; + } + + function positionAt(lineStarts, offset) { + var safeOffset = offset; + var low = 0; + var high = lineStarts.length - 1; + var mid; + + if (safeOffset < 0) { + safeOffset = 0; + } + + while (low <= high) { + mid = Math.floor((low + high) / 2); + if (lineStarts[mid] > safeOffset) { + high = mid - 1; + } else if (mid + 1 < lineStarts.length && lineStarts[mid + 1] <= safeOffset) { + low = mid + 1; + } else { + return { + line: mid, + character: safeOffset - lineStarts[mid] + }; + } + } + + return { + line: 0, + character: safeOffset + }; + } + + function normalizeTypeName(typeName) { + var normalized = trim(typeName).toLowerCase(); + + normalized = normalized.replace(/[<>]/g, ''); + normalized = normalized.replace(/\s+/g, ''); + normalized = normalized.replace(/date\/time/g, 'datetime'); + + if (normalized === 'boolean') { + return 'bool'; + } + if (normalized === 'time') { + return 'timeofday'; + } + if (normalized === 'table' || normalized === 'alias') { + return 'table'; + } + if (normalized === 'field') { + return 'field'; + } + if (normalized === 'record') { + return 'recordlist'; + } + if (normalized === 'null' || normalized === 'empty') { + return 'null'; + } + if (normalized === 'any') { + return 'any'; + } + + return normalized; + } + + function displayType(typeName) { + var normalized = normalizeTypeName(typeName); + var labels = { + any: 'Any', + bool: 'Boolean', + number: 'Number', + text: 'Text', + textlist: 'TextList', + date: 'Date', + datetime: 'DateTime', + duration: 'Duration', + timeofday: 'TimeOfDay', + workdate: 'WorkDate', + user: 'User', + userlist: 'UserList', + recordlist: 'RecordList', + table: 'Table', + field: 'Field', + 'null': 'Null', + reference: 'Reference', + unknown: 'Unknown' + }; + + return labels[normalized] || typeName; + } + + function normalizeFunctionName(name) { + var compact = trim(name).replace(/\s+/g, ' '); + var duplicateMatch = /^([A-Za-z_][A-Za-z0-9_]*)\s+\1$/i.exec(compact); + + if (duplicateMatch) { + compact = duplicateMatch[1]; + } + + return compact; + } + + function splitParameters(parameterText) { + var rawParts = parameterText.split(','); + var parts = []; + var index; + + for (index = 0; index < rawParts.length; index += 1) { + parts.push(trim(rawParts[index])); + } + + return parts; + } + + function parseParameter(part, previousType) { + var value = trim(part); + var variadic = false; + var match; + var typeName; + + if (!value) { + return null; + } + + if (value === '...') { + return { + type: previousType || 'any', + variadic: true, + placeholder: true + }; + } + + if (/\.\.\.?$/.test(value)) { + variadic = true; + value = trim(value.replace(/\.\.\.?$/, '')); + } + + match = /^<([^>]+)>/.exec(value); + if (match) { + typeName = match[1]; + } else { + match = /^([A-Za-z]+(?:\/[A-Za-z]+)?)/.exec(value); + typeName = match ? match[1] : 'any'; + } + + return { + type: normalizeTypeName(typeName), + variadic: variadic + }; + } + + function createSignature(parameters) { + var signature = { + params: [], + minArgs: 0, + maxArgs: 0, + variadic: false, + variadicType: 'any' + }; + var index; + var parameter; + var previousType = 'any'; + + for (index = 0; index < parameters.length; index += 1) { + parameter = parseParameter(parameters[index], previousType); + if (!parameter) { + continue; + } + + if (parameter.placeholder && parameter.variadic) { + signature.variadic = true; + signature.variadicType = previousType || 'any'; + continue; + } + + signature.params.push({ type: parameter.type }); + signature.minArgs += 1; + signature.maxArgs += 1; + previousType = parameter.type; + + if (parameter.variadic) { + signature.variadic = true; + signature.variadicType = parameter.type; + signature.maxArgs = Infinity; + } + } + + return signature; + } + + function parseSignatureKey(key) { + var match = /^(.*?)\s*\((.*)\)$/.exec(key); + var functionName; + var parameterText; + var signature; + + if (!match) { + return null; + } + + functionName = normalizeFunctionName(match[1]); + parameterText = trim(match[2]); + + if (!functionName) { + return null; + } + + signature = createSignature(parameterText ? splitParameters(parameterText) : []); + + return { + name: functionName, + lowerName: functionName.toLowerCase(), + signature: signature + }; + } + + function ensureCatalogEntry(catalog, name) { + if (!catalog[name]) { + catalog[name] = { + name: name, + signatures: [], + returnStrategy: RETURN_STRATEGIES[name] || null, + special: null, + summary: null, + docsUrl: null, + notes: null, + queryArgumentIndexes: null, + literalArgumentIndexes: null, + literalArgumentMessage: null + }; + } + + return catalog[name]; + } + + function cloneArray(values) { + var result = []; + var index; + + if (!values || !values.length) { + return result; + } + + for (index = 0; index < values.length; index += 1) { + result.push(values[index]); + } + + return result; + } + + function applyReferenceMetadata(entry, metadata) { + if (!entry || !metadata) { + return; + } + + if (metadata.summary) { + entry.summary = metadata.summary; + } + if (metadata.docsUrl) { + entry.docsUrl = metadata.docsUrl; + } + if (metadata.notes && metadata.notes.length) { + entry.notes = cloneArray(metadata.notes); + } + if (metadata.queryArgumentIndexes && metadata.queryArgumentIndexes.length) { + entry.queryArgumentIndexes = cloneArray(metadata.queryArgumentIndexes); + } + if (metadata.literalArgumentIndexes && metadata.literalArgumentIndexes.length) { + entry.literalArgumentIndexes = cloneArray(metadata.literalArgumentIndexes); + } + if (metadata.literalArgumentMessage) { + entry.literalArgumentMessage = metadata.literalArgumentMessage; + } + } + + function buildFunctionCatalog(snippets, referenceData) { + var catalog = {}; + var key; + var parsed; + var entry; + var override; + var signatureIndex; + var functionMetadata; + + for (key in snippets) { + if (snippets.hasOwnProperty && !snippets.hasOwnProperty(key)) { + continue; + } + + parsed = parseSignatureKey(key); + if (!parsed) { + continue; + } + + entry = ensureCatalogEntry(catalog, parsed.lowerName); + entry.name = parsed.name; + entry.signatures.push(parsed.signature); + } + + for (key in MANUAL_SIGNATURES) { + if (MANUAL_SIGNATURES.hasOwnProperty && !MANUAL_SIGNATURES.hasOwnProperty(key)) { + continue; + } + + override = MANUAL_SIGNATURES[key]; + entry = ensureCatalogEntry(catalog, key); + if (override.special) { + entry.special = override.special; + } + if (override.returnStrategy) { + entry.returnStrategy = override.returnStrategy; + } + if (override.signatures) { + entry.signatures = []; + for (signatureIndex = 0; signatureIndex < override.signatures.length; signatureIndex += 1) { + entry.signatures.push(override.signatures[signatureIndex]); + } + } + } + + functionMetadata = referenceData && referenceData.functionMetadata ? referenceData.functionMetadata : null; + if (functionMetadata) { + for (key in functionMetadata) { + if (functionMetadata.hasOwnProperty && !functionMetadata.hasOwnProperty(key)) { + continue; + } + + entry = ensureCatalogEntry(catalog, key); + applyReferenceMetadata(entry, functionMetadata[key]); + } + } + + return catalog; + } + + function createDiagnostic(code, severity, message, start, end, lineStarts) { + var startPosition = positionAt(lineStarts, start); + var endPosition = positionAt(lineStarts, end < start ? start : end); + + return { + code: code, + severity: severity, + message: message, + start: start, + end: end < start ? start : end, + startLine: startPosition.line, + startCharacter: startPosition.character, + endLine: endPosition.line, + endCharacter: endPosition.character + }; + } + + function Tokenizer(text, lineStarts) { + this.text = text || ''; + this.length = this.text.length; + this.offset = 0; + this.lineStarts = lineStarts; + this.diagnostics = []; + } + + Tokenizer.prototype.peek = function (distance) { + var safeDistance = typeof distance === 'number' ? distance : 0; + var index = this.offset + safeDistance; + + if (index < 0 || index >= this.length) { + return ''; + } + + return this.text.charAt(index); + }; + + Tokenizer.prototype.advance = function () { + this.offset += 1; + }; + + Tokenizer.prototype.skipTrivia = function () { + var character; + + while (this.offset < this.length) { + character = this.peek(0); + + if (character === '/' && this.peek(1) === '/') { + while (this.offset < this.length && this.peek(0) !== '\n') { + this.advance(); + } + continue; + } + + if (/\s/.test(character)) { + this.advance(); + continue; + } + + break; + } + }; + + Tokenizer.prototype.createToken = function (type, value, start, end, extras) { + var token = { + type: type, + value: value, + start: start, + end: end + }; + var key; + + if (extras) { + for (key in extras) { + if (extras.hasOwnProperty && !extras.hasOwnProperty(key)) { + continue; + } + token[key] = extras[key]; + } + } + + return token; + }; + + Tokenizer.prototype.readNumber = function () { + var start = this.offset; + var sawDecimal = false; + + while (this.offset < this.length) { + if (isDigit(this.peek(0))) { + this.advance(); + continue; + } + + if (this.peek(0) === '.' && !sawDecimal && isDigit(this.peek(1))) { + sawDecimal = true; + this.advance(); + continue; + } + + break; + } + + return this.createToken('number', this.text.slice(start, this.offset), start, this.offset); + }; + + Tokenizer.prototype.readIdentifier = function () { + var start = this.offset; + var value; + var lowerValue; + + this.advance(); + while (isIdentifierPart(this.peek(0))) { + this.advance(); + } + + value = this.text.slice(start, this.offset); + lowerValue = value.toLowerCase(); + + if (lowerValue === 'var') { + return this.createToken('keyword', lowerValue, start, this.offset); + } + + if (lowerValue === 'and' || lowerValue === 'or' || lowerValue === 'not') { + return this.createToken('operatorKeyword', lowerValue, start, this.offset); + } + + if (lowerValue === 'true' || lowerValue === 'false') { + return this.createToken('boolean', lowerValue, start, this.offset); + } + + if (lowerValue === 'null') { + return this.createToken('null', lowerValue, start, this.offset); + } + + return this.createToken('identifier', value, start, this.offset); + }; + + Tokenizer.prototype.readVariableReference = function () { + var start = this.offset; + + this.advance(); + if (!isIdentifierStart(this.peek(0))) { + this.diagnostics.push(createDiagnostic('QB001', 'error', 'Expected a variable name after $.', start, this.offset, this.lineStarts)); + return this.createToken('unknown', '$', start, this.offset); + } + + while (isIdentifierPart(this.peek(0))) { + this.advance(); + } + + return this.createToken('variableReference', this.text.slice(start, this.offset), start, this.offset); + }; + + Tokenizer.prototype.readBracketReference = function () { + var start = this.offset; + var closed = false; + + this.advance(); + while (this.offset < this.length) { + if (this.peek(0) === ']') { + this.advance(); + closed = true; + break; + } + if (this.peek(0) === '\n') { + break; + } + this.advance(); + } + + if (!closed) { + this.diagnostics.push(createDiagnostic('QB003', 'error', 'Unterminated bracketed reference.', start, this.offset, this.lineStarts)); + } + + return this.createToken('bracketReference', this.text.slice(start, this.offset), start, this.offset); + }; + + Tokenizer.prototype.readString = function () { + var start = this.offset; + var innerStart; + var closed = false; + + this.advance(); + innerStart = this.offset; + + while (this.offset < this.length) { + if (this.peek(0) === '\\') { + this.advance(); + if (this.offset < this.length) { + this.advance(); + } + continue; + } + + if (this.peek(0) === '"') { + closed = true; + break; + } + + if (this.peek(0) === '\n') { + break; + } + + this.advance(); + } + + if (closed) { + this.advance(); + } else { + this.diagnostics.push(createDiagnostic('QB002', 'error', 'Unterminated string literal.', start, this.offset, this.lineStarts)); + } + + return this.createToken('string', this.text.slice(innerStart, closed ? this.offset - 1 : this.offset), start, this.offset, { + raw: this.text.slice(start, this.offset), + contentStart: innerStart, + terminated: closed + }); + }; + + Tokenizer.prototype.nextToken = function () { + var start; + var twoCharacterOperator; + var character; + + this.skipTrivia(); + + if (this.offset >= this.length) { + return this.createToken('eof', '', this.offset, this.offset); + } + + start = this.offset; + character = this.peek(0); + twoCharacterOperator = character + this.peek(1); + + if (character === '$') { + return this.readVariableReference(); + } + + if (character === '[') { + return this.readBracketReference(); + } + + if (character === '"') { + return this.readString(); + } + + if (isDigit(character)) { + return this.readNumber(); + } + + if (isIdentifierStart(character)) { + return this.readIdentifier(); + } + + if (twoCharacterOperator === '>=' || twoCharacterOperator === '<=' || twoCharacterOperator === '!=' || twoCharacterOperator === '<>') { + this.advance(); + this.advance(); + return this.createToken('operator', twoCharacterOperator, start, this.offset); + } + + if (character === '=' || character === '>' || character === '<' || character === '+' || character === '-' || character === '*' || character === '/' || character === '&' || character === '^') { + this.advance(); + return this.createToken('operator', character, start, this.offset); + } + + if (character === '(') { + this.advance(); + return this.createToken('lparen', character, start, this.offset); + } + if (character === ')') { + this.advance(); + return this.createToken('rparen', character, start, this.offset); + } + if (character === ',') { + this.advance(); + return this.createToken('comma', character, start, this.offset); + } + if (character === ';') { + this.advance(); + return this.createToken('semicolon', character, start, this.offset); + } + + this.advance(); + this.diagnostics.push(createDiagnostic('QB001', 'error', 'Unexpected character "' + character + '".', start, this.offset, this.lineStarts)); + return this.createToken('unknown', character, start, this.offset); + }; + + function tokenize(text, lineStarts) { + var tokenizer = new Tokenizer(text, lineStarts); + var tokens = []; + var token; + + do { + token = tokenizer.nextToken(); + tokens.push(token); + } while (token.type !== 'eof'); + + return { + tokens: tokens, + diagnostics: tokenizer.diagnostics + }; + } + + function Parser(tokens, lineStarts) { + this.tokens = tokens; + this.lineStarts = lineStarts; + this.index = 0; + this.diagnostics = []; + } + + Parser.prototype.current = function () { + return this.tokens[this.index]; + }; + + Parser.prototype.previous = function () { + return this.tokens[this.index - 1] || this.tokens[0]; + }; + + Parser.prototype.isAtEnd = function () { + return this.current().type === 'eof'; + }; + + Parser.prototype.advance = function () { + if (!this.isAtEnd()) { + this.index += 1; + } + return this.previous(); + }; + + Parser.prototype.check = function (type, value) { + var token = this.current(); + + if (!token) { + return false; + } + if (token.type !== type) { + return false; + } + if (typeof value !== 'undefined' && token.value !== value) { + return false; + } + return true; + }; + + Parser.prototype.match = function (type, value) { + if (this.check(type, value)) { + this.advance(); + return true; + } + return false; + }; + + Parser.prototype.consume = function (type, message) { + var token = this.current(); + + if (this.check(type)) { + return this.advance(); + } + + this.diagnostics.push(createDiagnostic('QB001', 'error', message, token.start, token.end, this.lineStarts)); + return { + type: type, + value: '', + start: token.start, + end: token.end + }; + }; + + Parser.prototype.hasImplicitStatementSeparator = function (statementEnd) { + return positionAt(this.lineStarts, statementEnd).line < positionAt(this.lineStarts, this.current().start).line; + }; + + Parser.prototype.parseDocument = function () { + var statements = []; + var statement; + + while (!this.isAtEnd()) { + if (this.match('semicolon')) { + continue; + } + + if (this.check('keyword', 'var')) { + statement = this.parseVariableDeclaration(); + } else { + statement = this.parseExpressionStatement(); + } + + statements.push(statement); + + if (this.match('semicolon')) { + continue; + } + + if (!this.isAtEnd() && !this.check('rparen') && !this.check('comma')) { + if (this.hasImplicitStatementSeparator(statement.end)) { + continue; + } + + this.diagnostics.push(createDiagnostic('QB001', 'error', 'Expected a semicolon or the end of the formula.', this.current().start, this.current().end, this.lineStarts)); + this.advance(); + } + } + + return { + statements: statements, + diagnostics: this.diagnostics + }; + }; + + Parser.prototype.parseVariableDeclaration = function () { + var varToken = this.advance(); + var typeToken = this.consume('identifier', 'Expected a Quickbase data type after var.'); + var nameToken = this.consume('identifier', 'Expected a variable name after the declared type.'); + var initializer = null; + + if (this.match('operator', '=')) { + initializer = this.parseExpression(); + } else { + this.diagnostics.push(createDiagnostic('QB001', 'error', 'Expected = followed by a variable initializer.', this.current().start, this.current().end, this.lineStarts)); + } + + return { + type: 'VariableDeclaration', + keyword: varToken, + declaredType: typeToken.value, + name: nameToken.value, + nameStart: nameToken.start, + nameEnd: nameToken.end, + start: varToken.start, + end: initializer ? initializer.end : nameToken.end, + initializer: initializer + }; + }; + + Parser.prototype.parseExpressionStatement = function () { + var expression = this.parseExpression(); + + return { + type: 'ExpressionStatement', + expression: expression, + start: expression.start, + end: expression.end + }; + }; + + Parser.prototype.parseExpression = function () { + return this.parseBinaryExpression(1); + }; + + Parser.prototype.parseBinaryExpression = function (minimumPrecedence) { + var left = this.parseUnaryExpression(); + var token; + var precedence; + var operator; + var right; + + while (!this.isAtEnd()) { + token = this.current(); + precedence = getPrecedence(token); + if (precedence < minimumPrecedence) { + break; + } + + this.advance(); + operator = token.value; + right = this.parseBinaryExpression(precedence + 1); + left = { + type: 'BinaryExpression', + operator: operator, + left: left, + right: right, + start: left.start, + end: right.end + }; + } + + return left; + }; + + Parser.prototype.parseUnaryExpression = function () { + var token = this.current(); + var expression; + + if ((token.type === 'operatorKeyword' && token.value === 'not') || (token.type === 'operator' && token.value === '-')) { + this.advance(); + expression = this.parseUnaryExpression(); + return { + type: 'UnaryExpression', + operator: token.value, + expression: expression, + start: token.start, + end: expression.end + }; + } + + return this.parsePrimaryExpression(); + }; + + Parser.prototype.parsePrimaryExpression = function () { + var token = this.current(); + var expression; + var args; + var nameToken; + + if (this.match('number')) { + return { + type: 'NumberLiteral', + value: Number(token.value), + raw: token.value, + start: token.start, + end: token.end + }; + } + + if (this.match('string')) { + return { + type: 'StringLiteral', + value: token.value, + raw: token.raw, + contentStart: token.contentStart, + terminated: token.terminated, + start: token.start, + end: token.end + }; + } + + if (this.match('boolean')) { + return { + type: 'BooleanLiteral', + value: token.value === 'true', + start: token.start, + end: token.end + }; + } + + if (this.match('null')) { + return { + type: 'NullLiteral', + value: null, + start: token.start, + end: token.end + }; + } + + if (this.match('variableReference')) { + return { + type: 'VariableReference', + name: token.value.slice(1), + raw: token.value, + start: token.start, + end: token.end + }; + } + + if (this.match('bracketReference')) { + return { + type: 'BracketReference', + name: token.value, + start: token.start, + end: token.end + }; + } + + if (this.match('identifier')) { + nameToken = token; + if (this.match('lparen')) { + args = this.parseArguments(); + return { + type: 'CallExpression', + callee: nameToken.value, + arguments: args.arguments, + start: nameToken.start, + end: args.end + }; + } + + return { + type: 'Identifier', + name: nameToken.value, + start: nameToken.start, + end: nameToken.end + }; + } + + if (this.match('lparen')) { + expression = this.parseExpression(); + token = this.consume('rparen', 'Expected ) to close the parenthesized expression.'); + return { + type: 'ParenthesizedExpression', + expression: expression, + start: expression.start, + end: token.end + }; + } + + this.diagnostics.push(createDiagnostic('QB001', 'error', 'Expected an expression.', token.start, token.end, this.lineStarts)); + this.advance(); + return { + type: 'ErrorExpression', + start: token.start, + end: token.end + }; + }; + + Parser.prototype.parseArguments = function () { + var args = []; + var closingToken; + + if (this.match('rparen')) { + return { + arguments: args, + end: this.previous().end + }; + } + + do { + args.push(this.parseExpression()); + } while (this.match('comma')); + + closingToken = this.consume('rparen', 'Expected ) to close the function call.'); + return { + arguments: args, + end: closingToken.end + }; + }; + + function getPrecedence(token) { + if (!token) { + return 0; + } + if (token.type === 'operatorKeyword') { + if (token.value === 'or') { + return 1; + } + if (token.value === 'and') { + return 2; + } + } + if (token.type === 'operator') { + if (token.value === '=' || token.value === '!=' || token.value === '<>' || token.value === '>' || token.value === '<' || token.value === '>=' || token.value === '<=') { + return 3; + } + if (token.value === '&') { + return 4; + } + if (token.value === '+' || token.value === '-') { + return 5; + } + if (token.value === '*' || token.value === '/' || token.value === '^') { + return 6; + } + } + return 0; + } + + function collectDiagnostics(result, diagnostics) { + var index; + + for (index = 0; index < diagnostics.length; index += 1) { + result.push(diagnostics[index]); + } + + result.sort(function (left, right) { + if (left.start !== right.start) { + return left.start - right.start; + } + if (left.end !== right.end) { + return left.end - right.end; + } + return left.code < right.code ? -1 : 1; + }); + } + + function isKnownVariableType(typeName) { + return !!VARIABLE_TYPES[normalizeTypeName(typeName)]; + } + + function canAssignType(targetType, sourceType) { + var target = normalizeTypeName(targetType); + var source = normalizeTypeName(sourceType); + + if (target === 'any' || source === 'any' || source === 'unknown') { + return true; + } + if (source === 'null') { + return true; + } + if (target === source) { + return true; + } + + return false; + } + + function isNumberLike(typeName) { + var normalized = normalizeTypeName(typeName); + return normalized === 'number' || normalized === 'duration'; + } + + function isValidVariableName(name) { + return /^[A-Za-z]+$/.test(String(name || '')); + } + + function signatureAllowsCount(signature, argCount) { + if (signature.variadic) { + return argCount >= signature.minArgs; + } + return argCount >= signature.minArgs && argCount <= signature.maxArgs; + } + + function getExpectedTypeForArgument(signature, index) { + if (index < signature.params.length) { + return signature.params[index].type; + } + if (signature.variadic) { + return signature.variadicType || 'any'; + } + return null; + } + + function getArgumentTypes(args, state) { + var argTypes = []; + var index; + + for (index = 0; index < args.length; index += 1) { + argTypes.push(inferExpressionType(args[index], state)); + } + + return argTypes; + } + + function createTextTemplate(text, sourceStart, sourceEnd, dynamic) { + return { + text: String(text || ''), + segments: [{ + generatedStart: 0, + generatedEnd: String(text || '').length, + sourceStart: sourceStart, + sourceEnd: sourceEnd, + dynamic: !!dynamic + }] + }; + } + + function cloneTemplateSegments(segments, offset) { + var result = []; + var index; + var segment; + + for (index = 0; index < segments.length; index += 1) { + segment = segments[index]; + result.push({ + generatedStart: segment.generatedStart + offset, + generatedEnd: segment.generatedEnd + offset, + sourceStart: segment.sourceStart, + sourceEnd: segment.sourceEnd, + dynamic: segment.dynamic + }); + } + + return result; + } + + function combineTextTemplates(left, right) { + var combined = { + text: left.text + right.text, + segments: [] + }; + var index; + + for (index = 0; index < left.segments.length; index += 1) { + combined.segments.push(left.segments[index]); + } + combined.segments = combined.segments.concat(cloneTemplateSegments(right.segments, left.text.length)); + + return combined; + } + + function makeDynamicTextTemplate(node) { + return createTextTemplate('{{dynamic}}', node.start, node.end, true); + } + + function toTextTemplate(node, state) { + var left; + var right; + var symbol; + + if (!node) { + return null; + } + + switch (node.type) { + case 'StringLiteral': + return createTextTemplate(node.value, node.contentStart, node.contentStart + node.value.length, false); + case 'ParenthesizedExpression': + return toTextTemplate(node.expression, state); + case 'BinaryExpression': + if (node.operator !== '&') { + return makeDynamicTextTemplate(node); + } + left = toTextTemplate(node.left, state) || makeDynamicTextTemplate(node.left); + right = toTextTemplate(node.right, state) || makeDynamicTextTemplate(node.right); + return combineTextTemplates(left, right); + case 'VariableReference': + symbol = state.symbols[node.name]; + if (symbol && symbol.textTemplate) { + return symbol.textTemplate; + } + return makeDynamicTextTemplate(node); + default: + return makeDynamicTextTemplate(node); + } + } + + function looksLikeQueryText(text) { + return /\{[^{}]*\.[^{}]*\./.test(String(text || '')); + } + + function isStandaloneQueryDocument(text) { + return /^\s*\{/.test(String(text || '')); + } + + function mapGeneratedOffset(template, generatedOffset) { + var offset = generatedOffset; + var index; + var segment; + var relativeOffset; + + if (offset < 0) { + offset = 0; + } + + for (index = 0; index < template.segments.length; index += 1) { + segment = template.segments[index]; + if (offset < segment.generatedEnd || index === template.segments.length - 1) { + if (segment.dynamic) { + return segment.sourceStart; + } + relativeOffset = offset - segment.generatedStart; + if (relativeOffset < 0) { + relativeOffset = 0; + } + if (segment.sourceStart + relativeOffset > segment.sourceEnd) { + return segment.sourceEnd; + } + return segment.sourceStart + relativeOffset; + } + } + + return 0; + } + + function createTemplateDiagnostic(code, severity, message, start, end, template, lineStarts) { + return createDiagnostic( + code, + severity, + message, + mapGeneratedOffset(template, start), + mapGeneratedOffset(template, end), + lineStarts + ); + } + + function findQueryBlock(text, startIndex) { + var index = startIndex + 1; + var placeholderEnd; + + while (index < text.length) { + if (text.slice(index, index + 2) === '{{') { + placeholderEnd = text.indexOf('}}', index + 2); + if (placeholderEnd < 0) { + return null; + } + index = placeholderEnd + 2; + continue; + } + + if (text.charAt(index) === '}') { + return { + start: startIndex, + end: index + 1, + text: text.slice(startIndex, index + 1) + }; + } + + index += 1; + } + + return null; + } + + function isValidQueryField(fieldText) { + if (!fieldText) { + return false; + } + if (/^'[^']+'$/.test(fieldText)) { + return true; + } + if (/^\d+$/.test(fieldText)) { + return true; + } + return false; + } + + function isValidQueryValue(valueText) { + if (!valueText) { + return false; + } + if (/^'[^']*'$/.test(valueText)) { + return true; + } + if (/^"[^"]*"$/.test(valueText)) { + return true; + } + if (/^\{\{[^{}]+\}\}$/.test(valueText)) { + return true; + } + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(valueText)) { + return true; + } + return false; + } + + function validateQueryBlock(block, template, state) { + var queryText = block.text; + var innerText = trim(queryText.slice(1, queryText.length - 1)); + var firstDot = innerText.indexOf('.'); + var secondDot; + var fieldText; + var operatorSegment; + var operatorText; + var rawOperatorText; + var valueText; + var operatorWhitespace; + var operatorStart; + + if (firstDot < 0) { + state.diagnostics.push(createTemplateDiagnostic('QB108', 'error', 'Malformed Quickbase query block.', block.start, block.end, template, state.lineStarts)); + return; + } + + secondDot = innerText.indexOf('.', firstDot + 1); + if (secondDot < 0) { + state.diagnostics.push(createTemplateDiagnostic('QB108', 'error', 'Malformed Quickbase query block.', block.start, block.end, template, state.lineStarts)); + return; + } + + fieldText = trim(innerText.slice(0, firstDot)); + operatorSegment = innerText.slice(firstDot + 1, secondDot); + rawOperatorText = trim(operatorSegment); + operatorText = rawOperatorText.toUpperCase(); + valueText = trim(innerText.slice(secondDot + 1)); + + if (!isValidQueryField(fieldText)) { + state.diagnostics.push(createTemplateDiagnostic('QB108', 'error', 'Malformed Quickbase query field reference.', block.start, block.end, template, state.lineStarts)); + } + + operatorWhitespace = (/^\s*/.exec(operatorSegment) || [''])[0].length; + operatorStart = block.start + 1 + firstDot + 1 + operatorWhitespace; + if (QUERY_OPERATORS[operatorText] && rawOperatorText !== operatorText) { + state.diagnostics.push(createTemplateDiagnostic('QB110', 'error', 'Quickbase query operators must be uppercase.', operatorStart, operatorStart + rawOperatorText.length, template, state.lineStarts)); + } + if (!QUERY_OPERATORS[operatorText]) { + state.diagnostics.push(createTemplateDiagnostic('QB107', 'error', 'Invalid Quickbase query operator "' + operatorText + '".', operatorStart, operatorStart + rawOperatorText.length, template, state.lineStarts)); + } + + if (!isValidQueryValue(valueText)) { + state.diagnostics.push(createTemplateDiagnostic('QB108', 'error', 'Malformed Quickbase query value.', block.start, block.end, template, state.lineStarts)); + } + } + + function isValidQueryJoiner(text) { + return /^\s*(and|or)\s*$/i.test(String(text || '')); + } + + function validateLeadingQueryText(segmentText, start, end, template, state) { + if (!trim(segmentText)) { + return; + } + + state.diagnostics.push(createTemplateDiagnostic('QB108', 'error', 'Quickbase query text must start with a query block.', start, end, template, state.lineStarts)); + } + + function validateQueryJoiner(segmentText, start, end, template, state) { + if (!trim(segmentText)) { + return; + } + + if (isValidQueryJoiner(segmentText)) { + return; + } + + state.diagnostics.push(createTemplateDiagnostic('QB108', 'error', 'Quickbase query blocks must be joined with AND or OR.', start, end, template, state.lineStarts)); + } + + function validateTrailingQueryText(segmentText, start, end, template, state) { + var trimmed = trim(segmentText); + + if (!trimmed) { + return; + } + + if (/^(and|or)$/i.test(trimmed)) { + state.diagnostics.push(createTemplateDiagnostic('QB108', 'error', 'Expected another Quickbase query block after ' + trimmed.toUpperCase() + '.', start, end, template, state.lineStarts)); + return; + } + + state.diagnostics.push(createTemplateDiagnostic('QB108', 'error', 'Quickbase query blocks must be joined with AND or OR.', start, end, template, state.lineStarts)); + } + + function validateQueryTemplate(template, state) { + var text = template.text || ''; + var index; + var block; + var scanIndex = 0; + var segmentStart = 0; + var sawBlock = false; + var gapText; + + while (scanIndex < text.length) { + index = text.indexOf('{', scanIndex); + if (index < 0) { + break; + } + + if (text.charAt(index + 1) === '{') { + scanIndex = index + 2; + continue; + } + + gapText = text.slice(segmentStart, index); + if (sawBlock) { + validateQueryJoiner(gapText, segmentStart, index, template, state); + } else { + validateLeadingQueryText(gapText, segmentStart, index, template, state); + } + + block = findQueryBlock(text, index); + if (!block) { + state.diagnostics.push(createTemplateDiagnostic('QB108', 'error', 'Unclosed Quickbase query block.', index, text.length, template, state.lineStarts)); + break; + } + + validateQueryBlock(block, template, state); + scanIndex = block.end; + segmentStart = block.end; + sawBlock = true; + } + + if (!sawBlock) { + return; + } + + gapText = text.slice(segmentStart); + validateTrailingQueryText(gapText, segmentStart, text.length, template, state); + } + + function validateQueryArgument(node, state) { + var symbol; + var template; + + if (!node) { + return; + } + + if (node.type === 'VariableReference') { + symbol = state.symbols[node.name]; + if (symbol && symbol.queryTemplateValidated) { + return; + } + } + + template = toTextTemplate(node, state); + if (!template || !looksLikeQueryText(template.text)) { + return; + } + + validateQueryTemplate(template, state); + } + + function mergeTypes(types) { + var merged = 'unknown'; + var index; + var normalized; + + for (index = 0; index < types.length; index += 1) { + normalized = normalizeTypeName(types[index]); + if (normalized === 'unknown' || normalized === 'null') { + continue; + } + if (merged === 'unknown') { + merged = normalized; + continue; + } + if (merged !== normalized) { + return 'unknown'; + } + } + + return merged; + } + + function inferReturnType(functionName, argTypes) { + var strategy = RETURN_STRATEGIES[functionName] || 'unknown'; + var resultTypes; + var index; + var typeName; + + if (strategy === 'same-as-first') { + return argTypes.length ? argTypes[0] : 'unknown'; + } + + if (strategy !== 'unknown') { + return strategy; + } + + if (functionName === 'if') { + resultTypes = []; + for (index = 1; index < argTypes.length; index += 2) { + resultTypes.push(argTypes[index]); + } + if (argTypes.length % 2 === 1) { + resultTypes.push(argTypes[argTypes.length - 1]); + } + return mergeTypes(resultTypes); + } + + if (functionName === 'case') { + resultTypes = []; + for (index = 2; index < argTypes.length; index += 2) { + resultTypes.push(argTypes[index]); + } + if (argTypes.length % 2 === 0) { + resultTypes.push(argTypes[argTypes.length - 1]); + } + return mergeTypes(resultTypes); + } + + for (index = 0; index < argTypes.length; index += 1) { + typeName = normalizeTypeName(argTypes[index]); + if (typeName !== 'unknown' && typeName !== 'null') { + return typeName; + } + } + + return 'unknown'; + } + + function validateIfCall(node, argTypes, state) { + var index; + var resultTypes = []; + + if (argTypes.length < 2) { + state.diagnostics.push(createDiagnostic('QB103', 'warning', 'If() expects at least a condition and a result.', node.start, node.end, state.lineStarts)); + return 'unknown'; + } + + for (index = 0; index < argTypes.length - 1; index += 2) { + if (normalizeTypeName(argTypes[index]) !== 'bool' && normalizeTypeName(argTypes[index]) !== 'unknown') { + state.diagnostics.push(createDiagnostic('QB104', 'warning', 'If() condition arguments must be Boolean.', node.arguments[index].start, node.arguments[index].end, state.lineStarts)); + } + if (index + 1 < argTypes.length) { + resultTypes.push(argTypes[index + 1]); + } + } + + if (argTypes.length % 2 === 1) { + resultTypes.push(argTypes[argTypes.length - 1]); + } + + return mergeTypes(resultTypes); + } + + function validateCaseCall(node, argTypes, state) { + var selectorType; + var index; + var resultTypes = []; + + if (argTypes.length < 3) { + state.diagnostics.push(createDiagnostic('QB103', 'warning', 'Case() expects a selector, one match value, and one result.', node.start, node.end, state.lineStarts)); + return 'unknown'; + } + + selectorType = normalizeTypeName(argTypes[0]); + for (index = 1; index < argTypes.length - 1; index += 2) { + if (selectorType !== 'unknown' && normalizeTypeName(argTypes[index]) !== selectorType && normalizeTypeName(argTypes[index]) !== 'unknown') { + state.diagnostics.push(createDiagnostic('QB104', 'warning', 'Case() comparison values must match the selector type.', node.arguments[index].start, node.arguments[index].end, state.lineStarts)); + } + if (index + 1 < argTypes.length) { + resultTypes.push(argTypes[index + 1]); + } + } + + if (argTypes.length % 2 === 0) { + resultTypes.push(argTypes[argTypes.length - 1]); + } + + return mergeTypes(resultTypes); + } + + function isTypeCompatible(expectedType, actualType, node) { + var expected = normalizeTypeName(expectedType); + var actual = normalizeTypeName(actualType); + + if (!expected || expected === 'any' || actual === 'any' || actual === 'unknown' || actual === 'null') { + return true; + } + if (expected === actual) { + return true; + } + if ((expected === 'table' || expected === 'field') && node.type === 'BracketReference') { + return true; + } + + return false; + } + + function validateEntrySpecificRules(node, entry, state) { + var index; + var argumentIndex; + var argumentNode; + var message; + + if (entry.queryArgumentIndexes && entry.queryArgumentIndexes.length) { + for (index = 0; index < entry.queryArgumentIndexes.length; index += 1) { + argumentIndex = entry.queryArgumentIndexes[index]; + if (argumentIndex >= node.arguments.length) { + continue; + } + validateQueryArgument(node.arguments[argumentIndex], state); + } + } + + if (entry.literalArgumentIndexes && entry.literalArgumentIndexes.length) { + message = entry.literalArgumentMessage || 'This argument must be a string literal.'; + for (index = 0; index < entry.literalArgumentIndexes.length; index += 1) { + argumentIndex = entry.literalArgumentIndexes[index]; + if (argumentIndex >= node.arguments.length) { + continue; + } + argumentNode = node.arguments[argumentIndex]; + if (argumentNode.type !== 'StringLiteral') { + state.diagnostics.push(createDiagnostic('QB111', 'warning', message, argumentNode.start, argumentNode.end, state.lineStarts)); + } + } + } + } + + function validateGenericCall(node, entry, argTypes, state) { + var arityMatches = []; + var signatureIndex; + var argIndex; + var signature; + var firstMismatch = null; + var expectedType; + var mismatch; + + validateEntrySpecificRules(node, entry, state); + + for (signatureIndex = 0; signatureIndex < entry.signatures.length; signatureIndex += 1) { + signature = entry.signatures[signatureIndex]; + if (signatureAllowsCount(signature, node.arguments.length)) { + arityMatches.push(signature); + } + } + + if (!arityMatches.length) { + state.diagnostics.push(createDiagnostic('QB103', 'warning', 'No overload of ' + entry.name + ' accepts ' + node.arguments.length + ' argument(s).', node.start, node.end, state.lineStarts)); + return inferReturnType(node.callee.toLowerCase(), argTypes); + } + + for (signatureIndex = 0; signatureIndex < arityMatches.length; signatureIndex += 1) { + signature = arityMatches[signatureIndex]; + mismatch = null; + for (argIndex = 0; argIndex < node.arguments.length; argIndex += 1) { + expectedType = getExpectedTypeForArgument(signature, argIndex); + if (!isTypeCompatible(expectedType, argTypes[argIndex], node.arguments[argIndex])) { + mismatch = { + index: argIndex, + expectedType: expectedType, + actualType: argTypes[argIndex] + }; + break; + } + } + + if (!mismatch) { + return inferReturnType(node.callee.toLowerCase(), argTypes); + } + + if (!firstMismatch) { + firstMismatch = mismatch; + } + } + + if (firstMismatch) { + state.diagnostics.push(createDiagnostic( + 'QB104', + 'warning', + entry.name + '() argument ' + (firstMismatch.index + 1) + ' expects ' + displayType(firstMismatch.expectedType) + ' but received ' + displayType(firstMismatch.actualType) + '.', + node.arguments[firstMismatch.index].start, + node.arguments[firstMismatch.index].end, + state.lineStarts + )); + } + + return inferReturnType(node.callee.toLowerCase(), argTypes); + } + + function inferExpressionType(node, state) { + var leftType; + var rightType; + var entry; + var argTypes; + var normalizedType; + + if (!node) { + return 'unknown'; + } + + switch (node.type) { + case 'NumberLiteral': + return 'number'; + case 'StringLiteral': + return 'text'; + case 'BooleanLiteral': + return 'bool'; + case 'NullLiteral': + return 'null'; + case 'VariableReference': + if (!state.symbols[node.name]) { + state.diagnostics.push(createDiagnostic('QB101', 'error', 'Unknown variable $' + node.name + '.', node.start, node.end, state.lineStarts)); + return 'unknown'; + } + return state.symbols[node.name].type; + case 'BracketReference': + return 'unknown'; + case 'Identifier': + return 'unknown'; + case 'ParenthesizedExpression': + return inferExpressionType(node.expression, state); + case 'UnaryExpression': + leftType = inferExpressionType(node.expression, state); + if (node.operator === 'not') { + normalizedType = normalizeTypeName(leftType); + if (normalizedType !== 'bool' && normalizedType !== 'unknown') { + state.diagnostics.push(createDiagnostic('QB104', 'warning', 'The not operator expects a Boolean value.', node.expression.start, node.expression.end, state.lineStarts)); + } + return 'bool'; + } + if (node.operator === '-') { + if (!isNumberLike(leftType) && normalizeTypeName(leftType) !== 'unknown') { + state.diagnostics.push(createDiagnostic('QB104', 'warning', 'Unary - expects a numeric value.', node.expression.start, node.expression.end, state.lineStarts)); + } + return leftType; + } + return 'unknown'; + case 'BinaryExpression': + leftType = inferExpressionType(node.left, state); + rightType = inferExpressionType(node.right, state); + if (node.operator === 'and' || node.operator === 'or') { + if (normalizeTypeName(leftType) !== 'bool' && normalizeTypeName(leftType) !== 'unknown') { + state.diagnostics.push(createDiagnostic('QB104', 'warning', 'Logical operators expect Boolean operands.', node.left.start, node.left.end, state.lineStarts)); + } + if (normalizeTypeName(rightType) !== 'bool' && normalizeTypeName(rightType) !== 'unknown') { + state.diagnostics.push(createDiagnostic('QB104', 'warning', 'Logical operators expect Boolean operands.', node.right.start, node.right.end, state.lineStarts)); + } + return 'bool'; + } + if (node.operator === '=' || node.operator === '!=' || node.operator === '<>' || node.operator === '>' || node.operator === '<' || node.operator === '>=' || node.operator === '<=') { + return 'bool'; + } + if (node.operator === '&') { + return 'text'; + } + if (node.operator === '+' || node.operator === '-' || node.operator === '*' || node.operator === '/' || node.operator === '^') { + if ((!isNumberLike(leftType) && normalizeTypeName(leftType) !== 'unknown') || (!isNumberLike(rightType) && normalizeTypeName(rightType) !== 'unknown')) { + state.diagnostics.push(createDiagnostic('QB104', 'warning', 'Arithmetic operators expect numeric values.', node.start, node.end, state.lineStarts)); + return 'unknown'; + } + if (normalizeTypeName(leftType) === 'duration' || normalizeTypeName(rightType) === 'duration') { + return 'duration'; + } + return 'number'; + } + return 'unknown'; + case 'CallExpression': + entry = state.functionCatalog[node.callee.toLowerCase()]; + argTypes = getArgumentTypes(node.arguments, state); + if (!entry) { + state.diagnostics.push(createDiagnostic('QB105', 'warning', 'Unknown Quickbase function ' + node.callee + '().', node.start, node.end, state.lineStarts)); + return 'unknown'; + } + if (entry.special === 'if') { + return validateIfCall(node, argTypes, state); + } + if (entry.special === 'case') { + return validateCaseCall(node, argTypes, state); + } + return validateGenericCall(node, entry, argTypes, state); + case 'ErrorExpression': + return 'unknown'; + default: + return 'unknown'; + } + } + + function validateStatements(statements, functionCatalog, lineStarts) { + var diagnostics = []; + var symbols = {}; + var state = { + diagnostics: diagnostics, + functionCatalog: functionCatalog || {}, + lineStarts: lineStarts, + symbols: symbols + }; + var index; + var statement; + var declaredType; + var initializerType; + var existingSymbol; + var textTemplate; + var queryTemplateValidated; + + for (index = 0; index < statements.length; index += 1) { + statement = statements[index]; + + if (statement.type === 'VariableDeclaration') { + declaredType = normalizeTypeName(statement.declaredType); + if (!isKnownVariableType(declaredType)) { + diagnostics.push(createDiagnostic('QB106', 'warning', 'Unknown Quickbase variable type ' + statement.declaredType + '.', statement.start, statement.nameEnd, lineStarts)); + declaredType = 'unknown'; + } + + if (!isValidVariableName(statement.name)) { + diagnostics.push(createDiagnostic('QB109', 'error', 'Quickbase variable names must use letters only.', statement.nameStart, statement.nameEnd, lineStarts)); + } + + existingSymbol = symbols[statement.name]; + if (existingSymbol) { + diagnostics.push(createDiagnostic('QB100', 'error', 'Duplicate variable declaration $' + statement.name + '.', statement.nameStart, statement.nameEnd, lineStarts)); + } + + initializerType = statement.initializer ? inferExpressionType(statement.initializer, state) : 'unknown'; + textTemplate = statement.initializer ? toTextTemplate(statement.initializer, state) : null; + queryTemplateValidated = !!(textTemplate && looksLikeQueryText(textTemplate.text)); + if (queryTemplateValidated) { + validateQueryTemplate(textTemplate, state); + } + if (statement.initializer && !canAssignType(declaredType, initializerType)) { + diagnostics.push(createDiagnostic('QB102', 'warning', 'Declared type ' + displayType(declaredType) + ' does not match initializer type ' + displayType(initializerType) + '.', statement.initializer.start, statement.initializer.end, lineStarts)); + } + + symbols[statement.name] = { + type: declaredType, + declaration: statement, + textTemplate: textTemplate, + queryTemplateValidated: queryTemplateValidated + }; + continue; + } + + if (statement.type === 'ExpressionStatement') { + inferExpressionType(statement.expression, state); + } + } + + return diagnostics; + } + + function validateText(text, functionCatalog) { + var sourceText = text || ''; + var lineStarts = createLineStarts(sourceText); + var lexical; + var parser; + var parsed; + var diagnostics = []; + var semanticDiagnostics; + var queryState; + + if (isStandaloneQueryDocument(sourceText)) { + queryState = { + diagnostics: diagnostics, + functionCatalog: functionCatalog || {}, + lineStarts: lineStarts, + symbols: {} + }; + validateQueryTemplate(createTextTemplate(sourceText, 0, sourceText.length, false), queryState); + return { + diagnostics: diagnostics, + statements: [] + }; + } + + lexical = tokenize(sourceText, lineStarts); + parser = new Parser(lexical.tokens, lineStarts); + parsed = parser.parseDocument(); + semanticDiagnostics = validateStatements(parsed.statements, functionCatalog || {}, lineStarts); + + collectDiagnostics(diagnostics, lexical.diagnostics); + collectDiagnostics(diagnostics, parsed.diagnostics); + collectDiagnostics(diagnostics, semanticDiagnostics); + + return { + diagnostics: diagnostics, + statements: parsed.statements + }; + } + + return { + buildFunctionCatalog: buildFunctionCatalog, + validateText: validateText + }; +})); + + + + + + + + diff --git a/lib/quickbase-reference.js b/lib/quickbase-reference.js new file mode 100644 index 0000000..6cc00b2 --- /dev/null +++ b/lib/quickbase-reference.js @@ -0,0 +1,361 @@ +(function (root, factory) { + var api = factory(); + + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } + + if (root) { + root.QuickbaseReference = api; + } +}(this, function () { + var FUNCTION_METADATA = { + avg: { + summary: 'Returns the average value from a RecordList and field ID.', + docsUrl: 'https://help.quickbase.com/docs/quickbase-may-2025-release-notes' + }, + getaccesskey: { + summary: 'Returns a secure access key value for link-generation scenarios.', + docsUrl: 'https://help.quickbase.com/hc/en-us/articles/29353956195220-Secure-links', + notes: [ + 'Official help-center coverage for this function is limited; keep this function marked for source confirmation.' + ] + }, + getfieldvalues: { + summary: 'Returns the values from one field across the records in a RecordList.', + docsUrl: 'https://help.quickbase.com/docs/what-are-formula-queries', + notes: [ + 'Use this with GetRecords() or GetRecord() results.', + 'Quickbase documents this as a core formula-query function.' + ] + }, + getrecord: { + summary: 'Returns a RecordList containing one record by record ID.', + docsUrl: 'https://help.quickbase.com/docs/what-are-formula-queries' + }, + getrecordbyuniquefield: { + summary: 'Returns a RecordList containing one record located by a unique field value.', + docsUrl: 'https://help.quickbase.com/docs/quickbase-december-2022-release-notes' + }, + getrecords: { + summary: 'Returns a RecordList of records that match a Quickbase query string.', + docsUrl: 'https://help.quickbase.com/docs/build-queries-for-your-formulas', + queryArgumentIndexes: [0], + notes: [ + 'Query strings should be wrapped in double quotes.', + 'Use AND or OR to combine multiple query blocks.' + ] + }, + join: { + summary: 'Combines a TextList into a single Text value using a delimiter.', + docsUrl: 'https://help.quickbase.com/hc/en-us/articles/36386041144852-Quickbase-April-2025-Release-Notes' + }, + max: { + summary: 'Returns the maximum value, including formula-query aggregate usage on RecordLists.', + docsUrl: 'https://help.quickbase.com/docs/quickbase-may-2025-release-notes' + }, + median: { + summary: 'Returns the median value from a RecordList and field ID.', + docsUrl: 'https://help.quickbase.com/hc/en-us/articles/36386041144852-Quickbase-April-2025-Release-Notes' + }, + min: { + summary: 'Returns the minimum value, including formula-query aggregate usage on RecordLists.', + docsUrl: 'https://help.quickbase.com/docs/quickbase-may-2025-release-notes' + }, + regexextract: { + summary: 'Returns text matched by a regular expression pattern.', + docsUrl: 'https://help.quickbase.com/hc/en-us/articles/32800503697044-Regex-in-formulas', + literalArgumentIndexes: [1], + literalArgumentMessage: 'Regex pattern arguments must be string literals in Quickbase formulas.' + }, + regexmatch: { + summary: 'Returns true when the input matches a regular expression pattern.', + docsUrl: 'https://help.quickbase.com/hc/en-us/articles/32800503697044-Regex-in-formulas', + literalArgumentIndexes: [1], + literalArgumentMessage: 'Regex pattern arguments must be string literals in Quickbase formulas.' + }, + regexreplace: { + summary: 'Returns text after replacing matches from a regular expression pattern.', + docsUrl: 'https://help.quickbase.com/hc/en-us/articles/32800503697044-Regex-in-formulas', + literalArgumentIndexes: [1], + literalArgumentMessage: 'Regex pattern arguments must be string literals in Quickbase formulas.' + }, + sha256: { + summary: 'Returns a SHA-256 hash for the input text.', + docsUrl: 'https://help.quickbase.com/hc/en-us/articles/29353956195220-Secure-links' + }, + size: { + summary: 'Returns the size of a RecordList, TextList, or UserList.', + docsUrl: 'https://help.quickbase.com/docs/what-are-formula-queries' + }, + sumvalues: { + summary: 'Returns the numeric total for one field across the records in a RecordList.', + docsUrl: 'https://help.quickbase.com/docs/what-are-formula-queries' + }, + tounixtime: { + summary: 'Returns the Unix time representation of a Date/Time value.', + docsUrl: 'https://help.quickbase.com/hc/en-us/articles/29353956195220-Secure-links' + } + }; + + var QUERY_OPERATOR_METADATA = { + AF: { + summary: 'Matches values after the target date.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + BF: { + summary: 'Matches values before the target date.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + CT: { + summary: 'Contains the target value.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + EX: { + summary: 'Matches records where the field is equal to the target value.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + GT: { + summary: 'Matches values greater than the target value.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + GTE: { + summary: 'Matches values greater than or equal to the target value.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + HAS: { + summary: 'Used with List - User and Multi-select Text fields to require specific values.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + IR: { + summary: 'Matches dates during a relative date range.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + LT: { + summary: 'Matches values less than the target value.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + LTE: { + summary: 'Matches values less than or equal to the target value.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + OAF: { + summary: 'Matches values on or after the target date.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + OBF: { + summary: 'Matches values on or before the target date.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + SW: { + summary: 'Matches values that start with the target value.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + TV: { + summary: 'Matches a field true value, including relationship keys and user values.', + docsUrl: 'https://help.quickbase.com/docs/build-queries-for-your-formulas', + notes: [ + 'Quickbase recommends TV for user-field comparisons in formula queries.' + ] + }, + WC: { + summary: 'Wildcard match using * and ? characters.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + XCT: { + summary: 'Does not contain the target value.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + XEX: { + summary: 'Matches records where the field is not equal to the target value.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + XHAS: { + summary: 'Used with List - User and Multi-select Text fields to exclude specific values.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + XIR: { + summary: 'Matches dates not during a relative date range.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + XSW: { + summary: 'Matches values that do not start with the target value.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + }, + XTV: { + summary: 'True-value inequality comparison, often used with user fields.', + docsUrl: 'https://help.quickbase.com/docs/api-doquery' + } + }; + + var QUERY_SPECIAL_VALUE_METADATA = { + today: { + summary: "Special date query value that resolves to today's date for Date and Date/Time filters.", + docsUrl: 'https://help.quickbase.com/hc/en-us/articles/4418287696276-The-query-parameter', + notes: [ + 'Quickbase documents this value in single quotes inside query blocks.' + ] + }, + _curuser_: { + summary: 'Special user query value that resolves to the current user.', + docsUrl: 'https://help.quickbase.com/hc/en-us/articles/4418287696276-The-query-parameter', + notes: [ + 'Quickbase documents this value in single quotes and commonly pairs it with TV for user-field queries.' + ] + }, + relativedaysago: { + summary: "Relative date query value such as '2 days ago' or '-1 days ago'.", + docsUrl: 'https://help.quickbase.com/hc/en-us/articles/4418287696276-The-query-parameter', + notes: [ + 'Positive numbers look backward from today; negative numbers look forward.', + 'Quickbase documents these values in single quotes inside query blocks.' + ] + } + }; + + var VARIABLE_TYPE_METADATA = { + bool: { + label: 'Bool', + summary: 'Boolean variable type for true or false values.', + docsUrl: 'https://help.quickbase.com/docs/formula-variables' + }, + date: { + label: 'Date', + summary: 'Date variable type.', + docsUrl: 'https://help.quickbase.com/docs/formula-variables' + }, + datetime: { + label: 'DateTime', + summary: 'Date and time variable type.', + docsUrl: 'https://help.quickbase.com/docs/formula-variables' + }, + duration: { + label: 'Duration', + summary: 'Duration variable type.', + docsUrl: 'https://help.quickbase.com/docs/formula-variables' + }, + number: { + label: 'Number', + summary: 'Numeric variable type.', + docsUrl: 'https://help.quickbase.com/docs/formula-variables' + }, + recordlist: { + label: 'RecordList', + summary: 'Intermediate list of records, typically consumed by formula-query functions.', + docsUrl: 'https://help.quickbase.com/docs/what-are-formula-queries' + }, + text: { + label: 'Text', + summary: 'Text variable type.', + docsUrl: 'https://help.quickbase.com/docs/formula-variables' + }, + textlist: { + label: 'TextList', + summary: 'List of text values, often returned by GetFieldValues().', + docsUrl: 'https://help.quickbase.com/docs/what-are-formula-queries' + }, + timeofday: { + label: 'TimeOfDay', + summary: 'Time-of-day variable type.', + docsUrl: 'https://help.quickbase.com/docs/formula-variables' + }, + user: { + label: 'User', + summary: 'User variable type.', + docsUrl: 'https://help.quickbase.com/docs/formula-variables' + }, + workdate: { + label: 'WorkDate', + summary: 'WorkDate variable type.', + docsUrl: 'https://help.quickbase.com/docs/formula-variables' + } + }; + + var SYNTAX_METADATA = { + bracketReference: { + label: 'Field or Application Variable', + summary: 'Square-bracket references resolve a field value or an application variable.', + docsUrl: 'https://help.quickbase.com/docs/formula-components' + }, + queryField: { + label: 'Quickbase Query Field ID', + summary: 'The first part of a query block identifies the field ID being filtered.', + docsUrl: 'https://help.quickbase.com/docs/build-queries-for-your-formulas' + }, + variableKeyword: { + label: 'var', + summary: 'Declares a formula variable using `var = ;`.', + docsUrl: 'https://help.quickbase.com/docs/formula-variables' + }, + variableReference: { + label: 'Formula Variable', + summary: 'Formula variables are referenced with a leading `$`.', + docsUrl: 'https://help.quickbase.com/docs/formula-variables' + } + }; + + function normalizeName(value) { + return String(value || '').toLowerCase(); + } + + function cloneEntry(entry) { + var clone = {}; + var key; + + if (!entry) { + return null; + } + + for (key in entry) { + if (entry.hasOwnProperty && !entry.hasOwnProperty(key)) { + continue; + } + clone[key] = entry[key]; + } + + return clone; + } + + function getFunctionMetadata(name) { + return cloneEntry(FUNCTION_METADATA[normalizeName(name)]); + } + + function getQueryOperatorMetadata(name) { + return cloneEntry(QUERY_OPERATOR_METADATA[String(name || '').toUpperCase()]); + } + + function getQuerySpecialValueMetadata(value) { + var normalized = normalizeName(String(value || '').replace(/^'|'$/g, '')); + + if (QUERY_SPECIAL_VALUE_METADATA[normalized]) { + return cloneEntry(QUERY_SPECIAL_VALUE_METADATA[normalized]); + } + + if (/^-?\d+\s+days\s+ago$/.test(normalized)) { + return cloneEntry(QUERY_SPECIAL_VALUE_METADATA.relativedaysago); + } + + return null; + } + + function getVariableTypeMetadata(name) { + return cloneEntry(VARIABLE_TYPE_METADATA[normalizeName(name)]); + } + + function getSyntaxMetadata(name) { + return cloneEntry(SYNTAX_METADATA[name]); + } + + return { + functionMetadata: FUNCTION_METADATA, + queryOperatorMetadata: QUERY_OPERATOR_METADATA, + querySpecialValueMetadata: QUERY_SPECIAL_VALUE_METADATA, + syntaxMetadata: SYNTAX_METADATA, + variableTypeMetadata: VARIABLE_TYPE_METADATA, + getFunctionMetadata: getFunctionMetadata, + getQueryOperatorMetadata: getQueryOperatorMetadata, + getQuerySpecialValueMetadata: getQuerySpecialValueMetadata, + getSyntaxMetadata: getSyntaxMetadata, + getVariableTypeMetadata: getVariableTypeMetadata + }; +})); \ No newline at end of file diff --git a/package.json b/package.json index 080331d..62a8dc3 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,15 @@ { "name": "quick-base-formula-syntax", - "displayName": "Quickbase Formula Syntax Highlighter", - "description": "Syntax Highlighting for Quickbase Formulas", - "version": "0.0.1", + "displayName": "Quickbase Formula & Query Tools", + "description": "Syntax highlighting, snippets, and validation for Quickbase formulas and API query strings", + "version": "0.1.0", "publisher": "klubnik", "icon": "images/icon.png", "repository": "https://github.com/jdklub/vscode-quickbase-formula", + "main": "./extension.js", + "activationEvents": [ + "onLanguage:quickbase" + ], "engines": { "vscode": "^1.13.0" }, @@ -32,4 +36,4 @@ } ] } -} \ No newline at end of file +} diff --git a/reference/QuickBase Formula Documentation.csv b/reference/QuickBase Formula Documentation.csv new file mode 100644 index 0000000..eae25d9 --- /dev/null +++ b/reference/QuickBase Formula Documentation.csv @@ -0,0 +1,476 @@ +Category,Function Name,Arguments,Result Type,Explanation,Examples +Binary Operators (act on multiple values),!=," x, y",Boolean,"Same as <>. Returns true if x is not equal to y, otherwise returns false. x and y must be the same type. As with most other functions, null argument values produce a null result, so it is not possible to test for null with this operator. To test for null, use IsNull() instead.",(3 / 4 <> 0.75) returns false +Binary Operators (act on multiple values),&," u, v",Text,"Returns a text value that is the concatenation of u and v. Use this operator to link strings of characters together in a formula text field. If u or v is not a text value, QuickBase automatically inserts the function ToText() to convert the value to text before concatenating it. Does not support User or UserList.","""abc"" & ""def"" returns ""abcdef""" +,,,,,"""abc"" & 5 returns ""abc5""" +,,,,,"[First Name] & "" "" & [Last Name] concatenates the values in the First Name and Last Name field with a space in between them." +Binary Operators (act on multiple values),*,"Number m, Number n",Number,Returns the product of m times n.,[Price] * [Number of Units] multiplies the value in the Price field by the value in the Number of units field. +,,,,,-3 * 4 returns -12 +,,,,,0.5 * 5 returns 2.5 +Binary Operators (act on multiple values),*,"Number n, Duration d",Duration,"Returns a new Duration that is the given Duration d repeated n times. In other words, it's the duration multiplied by the number n.",3 * Days(2) returns 6 days +Binary Operators (act on multiple values),*,"Duration d, Number n",Duration,"Returns a new Duration that is the given Duration d repeated n times. In other words, it's the duration multiplied by the number n.",Weeks(2) * 3 returns 6 weeks +Unary Operators (act on single values),+,Number n,Number,Returns n. A +plus sign in front of a number returns a positive number.,+5 returns 5 +,,,,,+-5 returns -5 +Unary Operators (act on single values),+,Duration d,Duration,Returns d. A +plus sign in front of a duration returns a positive duration.,+ Weeks(3) returns 3 weeks +Binary Operators (act on multiple values),+,"Number m, Number n",Number,Returns the sum of m and n.,3 + 4 returns 7 +,,,,,[Subtotal] + [Tax] returns the sum achieved by adding the value in the Subtotal field to the value in the Tax field. +Binary Operators (act on multiple values),+,"Duration x, Duration y",Duration,Returns the duration that is the sum of x and y.,Days(1) + Days(2) returns 3 days +Binary Operators (act on multiple values),+,"Date d, Duration x",Date,Returns the date that is after date d by the duration x. x is truncated to a whole number of days.,[Start Date] + [Duration] returns the date you get by adding the value in the Duration field to the date in the Start Date field. +,,,,,"ToDate(""Jan 1, 2000"") + Days(2) returns Jan 3, 2000" +Binary Operators (act on multiple values),+,"Duration x, Date d",Date,Returns the date that is after date d by the duration x. x is truncated to a whole number of days.,"Days(2) + ToDate(""Jan 1, 2000"") returns Jan 3, 2000" +Binary Operators (act on multiple values),+,"Date/Time t, Duration x",Date/Time,Returns the Date/Time that is after Date/Time t by the duration x.,[Call Time] + [Review Period] returns the date and time that follows the date/time in the Call Time field by the duration listed in the Review Period field. +Binary Operators (act on multiple values),+,"Duration x, Date/Time t",Date/Time,Returns the Date/Time that is after Date/Time t by the duration x.,[Hours Worked] + [Start Time] returns the time of day resulting from adding the value in the Hours Worked field to the time in the Start Time field. +Binary Operators (act on multiple values),+,"Duration d, TimeOfDay t",TimeOfDay,Returns the TimeOfDay that is after TimeOfDay t by the duration x.,"ToTimeOfDay(""2 pm"") + Hours(22) returns 12 pm" +Binary Operators (act on multiple values),+,"TimeOfDay t, Duration d",TimeOfDay,Returns the TimeOfDay that is after TimeOfDay t by the duration x.,[Start Time] + [Hours Worked] returns the time of day resulting from adding the value in the Hours Worked field to the time in the Start Time field. +Unary Operators (act on single values),-,Number n,Number,Returns the arithmetic negative of n.,-5 returns -5 +,,,,,--5 returns 5 +Unary Operators (act on single values),-,Duration d,Duration,Returns the arithmetic negative of d.,-Weeks(3) returns -3 weeks +,,,,,-Weeks(-3) returns 3 weeks +Binary Operators (act on multiple values),-,"Number m, Number n",Number,Returns the difference of m and n.,7 - 3 returns 4 +,,,,,-7 - 2 returns -9 +Binary Operators (act on multiple values),-,"Duration x, Duration y",Duration,Returns the difference of x and y.,Weeks(1) - Days(2) returns 5 days +,,,,,Days(1) - Weeks(1) returns -6 days +Binary Operators (act on multiple values),-,"Date d, Duration x",Date,Returns the date that is before date d by the duration x. x is truncated to a whole number of days.,[Finish] - [Duration] returns the date you get by subracting the value in the Duration field from the date in the Finish field. +,,,,, +,,,,,"ToDate(""Jan 3, 2000"") - Days(2) returns Jan 1, 2000" +Binary Operators (act on multiple values),-,"Date d, Date e",Duration,Returns the duration between dates d and e.,[Actual Date Completed] - [Forecast Date] returns the duration between the date in the Actual Date Completed field and the one in the Forecast Date field. +,,,,, +,,,,,"ToDate(""Jan 3, 2000"") - ToDate(""Jan 1, 2000"")  returns 2 days" +Binary Operators (act on multiple values),-,"Date/Time t, Duration x",Date/Time,Returns the Date/Time that is before Date/Time t by the duration x.,[Event Start] - [Days to Prepare] returns the Date and time that precedes the event start by the number of days in the Days to Prepare field. +Binary Operators (act on multiple values),-,"Date/Time t, Date/Time u",Duration,Returns the duration between Date/Time values t and u.,[Created] - [Time/Date Resolved] +Binary Operators (act on multiple values),-,"TimeOfDay t, Duration d",TimeOfDay,Returns the TimeOfDay that is before TimeOfDay t by the duration d.,[Meeting Start] - [Hours of Prep] returns the time that precedes the Meeting Start time by the number of hours listed in the Hours of Prep field. +Binary Operators (act on multiple values),-,"TimeOfDay t, TimeOfDay u",Duration,Returns the duration between TimeOfDay t and TimeOfDay u. The result may be positive or negative depending on whether t is after or before u.,[End] - [Start] returns the duration you get by subtracting the time value in the Start field from the value in the End field. +,,,,,"ToTimeOfDay(""3pm"") - ToTimeOfDay(""2pm"") returns 1 hour" +,,,,,"ToTimeOfDay(""2pm"") - ToTimeOfDay(""3pm"") returns -1 hour" +Binary Operators (act on multiple values),/,"Number m, Number n",Number,Returns m divided by n.,6 / 4 returns 1.5 +Binary Operators (act on multiple values),/,"Duration x, Duration y",Number,Returns the number of times that Duration y divides into x.,Weeks(1) / Days(1) returns 7 +Binary Operators (act on multiple values),/,"Duration x, Number n",Duration,Returns a new Duration that is x divided by n.,Weeks(2) / 2 returns 1 week +Binary Operators (act on multiple values),<," x, y",Boolean,"Returns true if x is less than y, otherwise returns false. x and y must be the same type. For Numbers, the comparison is numerical. For Durations, the comparison is done using length of time represented. For Text, the comparison is done by alphabetical sort order. For Dates, Date/Time, and Time of Day values, the comparison is done by chronological order. For Booleans, false is less than true.",3 < 4 returns true +,,,,,4 < 3 returns false +,,,,,"""abcdef"" < ""gh"" returns true" +Binary Operators (act on multiple values),<=," x, y",Boolean,"Returns true if x is less than or equal to y, otherwise returns false. x and y must be the same type. For Numbers, the comparison is numerical. For Durations, the comparison is done using length of time represented. For Text, the comparison is done by alphabetical sort order. For Dates, Date/Time, and Time of Day values, the comparison is done by chronological order. For Booleans, false is less than true.",3 <= 4 returns true +,,,,,"""abcdef"" <= ""gh"" returns true" +Binary Operators (act on multiple values),<>," x, y",Boolean,"Same as !=. Returns true if x is not equal to y, otherwise returns false. x and y must be the same type. As with most other functions, null argument values produce a null result, so it is not possible to test for null with this operator. To test for null, use IsNull() instead.",(3 / 4 <> 0.75) returns false +Binary Operators (act on multiple values),=," x, y",Boolean,"Returns true if x is equal to y, otherwise returns false. x and y must be the same type. As with most other functions, null argument values produce a null result, so it is not possible to test for null with this operator. To test for null, use IsNull() instead.",(3 / 4 = 0.75) returns true +Binary Operators (act on multiple values),>," x, y",Boolean,"Returns true if x is greater than y, otherwise returns false. x and y must be the same type. For Numbers, the comparison is numerical. For Durations, the comparison is done using length of time represented. For Text, the comparison is done by alphabetical sort order. For Dates, Date/Time, and Time of Day values, the comparison is done by chronological order. For Booleans, false is less than true.",3 > 4 returns false +,,,,, +,,,,,[Actual Completion Date] >= [Projected Completion Date] returns true if the value in the the Actual Completion Date field is greater than the date in the Project Completion Date field. +,,,,, +,,,,,Weeks(1) > Days(6) returns true +Binary Operators (act on multiple values),>=," x, y",Boolean,"Returns true if x is greater than or equal to y, otherwise returns false. x and y must be the same type. For Numbers, the comparison is numerical. For Durations, the comparison is done using length of time represented. For Text, the comparison is done by alphabetical sort order. For Dates, Date/Time, and Time of Day values, the comparison is done by chronological order. For Booleans, false is less than true.",[Actual Completion Date] >= [Projected Completion Date] returns true if the value in the the Actual Completion Date field is greater than or equal to the date in the Project Completion Date field. +,,,,, +,,,,,"ToDate(""Jan 1, 2000"") >= ToDate(""Feb 1, 1999"") returns true" +Binary Operators (act on multiple values),^,"Number m, Number n",Number,Returns m raised to the nth power.,2 ^ 3 returns 8 +,,,,,9 ^ 0.5 returns 3 +,,,,,2 ^ -3 returns 0.125 +Numbers,Abs,(Number n),Number,Returns the absolute value of the Number n.,Abs(3.5) returns 3.5 +,,,,,Abs(-3.5) returns 3.5 +Durations,Abs,(Duration d),Duration,Returns the absolute value of d.,Abs(Weeks(3.5)) returns 3.5 weeks +,,,,,Abs(Weeks(-3.5)) returns 3.5 weeks +Dates,AdjustMonth,"(Date d, Number m)",Date,"Returns the date which is m months after the given date d, with the same day number. If the day doesn't exist in that month, the last day of that month is returned.","AdjustMonth([Ordered On], 3) returns the date three months after the date in the Ordered On field." +,,,,, +,,,,,"AdjustMonth(ToDate(""2/20/99""), 2) returns April 20, 1999" +,,,,,"AdjustMonth(ToDate(""4/29/99""), -2) returns February 28, 1999" +Dates,AdjustYear,"(Date d, Number y)",Date,"Returns the date which is y years after the given date d, with the same month and day. If the day doesn't exist in that month, the last day of that month is returned.","AdjustYear([Last Appt], 1) returns the date one year after the date in the Last Appt field." +,,,,,"AdjustYear([Date],-1) returns the date one year before the value in the Date field," +,,,,,"AdjustYear(ToDate(""2/20/99""), 2) returns February 20, 2001" +,,,,,"AdjustYear(ToDate(""2/29/00""), -1) returns February 28, 1999" +Binary Operators (act on multiple values),and,"Boolean a, Boolean b",Boolean,"Returns true if a and b are both true, otherwise returns false. Use this operator to link conditions together.",(true and false) returns false +,,,,, +,,,,,"[Decision - Technical Lead]=""Approved"" and" +,,,,,"[Decision - Project Manager]=""Approved"" and " +,,,,,"[Decision - Project Sponsor]=""Approved"" " +,,,,,"returns true if all three fields contain the value ""approved""" +Special,AppID,(),Text,Returns a text value containing the database ID of the app.,"AppID() in this app returns ""6ewwzuuj""" +Aggregation,Average,"(Number n, ...)",Number,Returns the average of all the arguments (except any null values).,"Average(12, 6, null) returns 9" +,,,,, +Aggregation,Average,"(Duration d, ...)",Duration,Returns the average of all the arguments (except any null values).,"Average(Days(1), Days(3)) returns 2 days" +Aggregation,Average,"(Date d, ...)",Date,Returns the average of all the arguments (except any null values).,"Average(ToDate(""1/1/2000""), ToDate(""1/3/2000"")) returns the date 1/2/2000" +Aggregation,Average,"(Date/Time t, ...)",Date/Time,Returns the average of all the arguments (except any null values).,"average([Actual Finish], [Planned Finish])" +Aggregation,Average,"(TimeOfDay t, ...)",TimeOfDay,Returns the average of all the arguments (except any null values).,"Average([Mon Start Time], [Tues Start Time], [Wed Start Time]) returns the average of all three start times." +Encoding Functions,Base64Decode,(Text t),Text,Decodes text using base64 encoding.,"Base64Decode(""aGVsbG8gd29ybGQ="") returns ""hello world""" +Encoding Functions,Base64Encode,(Text t),Text,Encodes text using base64 encoding.,"Base64Encode(""hello world"") returns ""aGVsbG8gd29ybGQ=""" +Text,Begins,"(Text u, Text v)",Boolean,"Returns true if the text u begins with the text v, otherwise returns false.","Begins(""abcdef"", ""cd"") returns false" +,,,,,"Begins(""abcdef"", ""abcd"") returns true" +Special,Case,"( x, val1, result1, ..., else-result)",,"Case() is a variation of the If() function. If you want to test many conditions against a single field, use the Case() function instead of the If() function. QuickBase evaluates the value x and compares it to each of the values that follow (val1 and so on) sequentially. If the value X matches any value, QuickBase returns the corresponding result which lives behind the comma following the matched value. If value x is not equal to any of the values, QuickBase returns the else-result at the end of the formula. The else-result is optional. If omitted, QuickBase assumes it's null (empty). The value x may be of any data type, but all of the values must be of the same type as x.","Case([Grade], ""A"", 100, ""B"", 90, null)" +,,,,, +,,,,,"This formula says: If the value in the Grade field is A, then return 100. If the value in the Grade field is B, then return 90. Otherwise, return nothing (null)." +,,,,, +Rounding and Truncating,Ceil,(Number x),Number,Returns the smallest integer greater than or equal to the number x.,Ceil(3) returns 3 +,,,,,Ceil(3.4) returns 4 +,,,,,Ceil(-3.4) returns -3 +Rounding and Truncating,Ceil,"(Number x, Number y)",Number,Returns the smallest multiple of y which is greater than or equal to x.,"Ceil(3.5, 2) returns 4" +,,,,,"Ceil(-3.5, 2) returns -2" +Rounding and Truncating,Ceil,"(Duration x, Duration y)",Duration,Returns the smallest multiple of the duration y which is greater than or equal to the duration x.,"Ceil(Days(3.5), Days(2)) returns Days(4)" +Text,Contains,"(Text u, Text v)",Boolean,"Returns true if v is contained in u, otherwise returns false.","Contains(""abcdef"", ""cd"") returns true" +,,,,,"Contains([Status], ""open"") returns true if the Status field contains the word ""open""" +TextList,Contains,"(TextList textList, Text textToSearchFor)",Boolean,"Returns true if textToSearchFor is contained in textList, otherwise returns false. Comparison is not case sensitive.","Contains([Skills Required], ""Kung fu"") returns true if the ""Skills required"" field has as one of its selected options ""Kung fu""" +UserList,Contains,"Contains (UserList userList, User userToSearchFor)",Boolean,"Returns true if userToSearchFor is contained in userList, otherwise returns false.","Contains([Team members], [Assigned To]) returns true if the value of the ""Assigned To"" field appears in the ""Team members"" list" +Aggregation,Count,"( x, ...)",Number,"Counts the number of non-null arguments. For Text arguments, non-blanks are counted. For Boolean arguments, trues are counted. This function can also be used in the context of a Summary report where it will count the # of Non-null records for each grouping, if used to specify the field to check in a Calculated Column.","Count ("""", ""abc"", true, false, 53) returns 3" +,,,,, +,,,,,"For the Summary Report example, a formula might look something like this:" +,,,,, +,,,,,Count([Field]) +,,,,, +,,,,,This might be used if the customer were trying to determine - within the Summary Report groups - how many of these records had a value in [Field] +Dates,Date,"(Number year, Number month, Number day)",Date,"Creates a date from a year, month and day.","Date(2000, 1, 10) returns the date January 10, 2000" +Dates,Day,(Date d),Number,Returns the day of the month of the given Date d.,Day([Start Date]) returns the day of the month for the date that appears in the Start Date field. +,,,,, +,,,,,"Day(ToDate(""Jan 10, 2000"")) returns 10" +Dates,DayOfWeek,(Date d),Number,Returns the number of days by which the given date d follows the first day of the week (Sunday returns 0).,DayOfWeek([Start Date]) returns the number of the day of the week for the date that appears in the Start Date field. +,,,,,"DayOfWeek(ToDate(""Aug 23, 2000"")) returns 3" +,,,,,"DayOfWeek(ToDate(""Aug 20, 2000"")) returns 0" +Dates,DayOfYear,(Date d),Number,Returns the number of days by which the given date d follows the first day of the year (January 1 returns 0).,"DayOfYear(ToDate(""Jan 1, 2000"")) returns 0" +,,,,,"DayOfYear(ToDate(""Jan 10, 2000"")) returns 9" +Durations,Days,(Number n),Duration,"Returns a Duration representing n days. This function takes a number and converts it into a Duration type value, expressed in days.",Days(1.5) returns a 1.5 day duration +,,,,,"Days([Estimated # of days]) converts the numeric value in the Estimated # of days field into a duration expressed in days. The number doesn't change, just the data type." +Special,Dbid,(),Text,Returns a Text value containing the database ID of a table.,"Dbid() in this database returns ""6ewwzuuj""" +Text,Ends,"(Text u, Text v)",Boolean,"Returns true if the text u ends with the text v, otherwise returns false.","Ends(""abcdef"", ""cd"") returns false" +,,,,,"Ends(""abcdef"", ""cdef"") returns true" +Numbers,Exp,(Number n),Number,"Returns e raised to the nth power, where e is approximately 2.71828182845905 This exponential function is for use in logarithmic calculations that track growth. For example, you can use it to figure compounding interest.","Exp(2) returns 7.389056 (which is e, or 2.71828182845905, to the 2nd power)" +,,,,, +,,,,,Exp(5) returns 148.413159 (e to the 5th power) +Text,Find,"(Text str, Text searchString)",Numeric,"Returns the index where searchString starts to appear in str. If not found, returns 0.","Example: Find(""Hello World"", ""Wo"") returns 7" +,,,,,"Find(""Hello World"", ""H"") returns 1" +,,,,,"Find(""Hello World"", ""X"") returns 0" +Dates,FirstDayOfMonth,(Date d),Date,Returns the first day of the month in which the date falls.,FirstDayOfMonth([Order Date]) returns the first day of the month in which the Order Date occurs. +,,,,, +,,,,,FirstDayOfMonth(Today()) returns the first day of the current month. +Dates,FirstDayOfPeriod,"(Date d, Duration p, Date r)",Date,"Returns the first day of the period in which the date d falls. The cycle of periods is defined by the given Duration p, repeated in sequence starting at the given reference date r. If the period p is not a whole number of days, the fractional part is ignored.",Useful for handling biweekly pay periods. +,,,,, +,,,,,"FirstDayOfPeriod([date field], Weeks(2), Date(2000,5,1)) returns the date that is the start of the two week period in which the date in the date field falls. The initial two week period that starts the cycle begins on May 1, 2000." +,,,,, +,,,,, +,,,,,"FirstDayOfPeriod(Today(), Days(365), Date(2005,7,1)) returns the date July 1st of the current year-long period. Between 7/1/06 and 7/1/07 this would be July 1, 2006. (As the years go on, this method won't account for leap years.)" +Dates,FirstDayOfWeek,(Date d),Date,Returns the first day (Sunday) of the week in which the date falls.,FirstDayOfWeek([Start Date]) returns the date of the Sunday (first day of the week) in which the Start Date occurs. +,,,,,"FirstDayOfWeek(ToDate(""7/30/2007"")) returns the date 7-29-07" +Dates,FirstDayOfYear,(Date d),Date,Returns the first day of the year in which the date falls.,FirstDayOfYear([Termination Date]) returns the first day of the year in which the Termination date occurs. +,,,,, +,,,,,FirstDayOfYear(Today()) returns the first day of the current year. +Rounding and Truncating,Floor,(Number x),Number,"Returns the largest integer less than or equal to the number x. Note that if x is a negative fraction, the result is closer to negative infinity than x is (compare to function Int).",Floor(3) returns 3 +,,,,,Floor(3.4) returns 3 +,,,,,Floor (3.8) returns 3 +,,,,,Floor(-3.4) returns -4 +Rounding and Truncating,Floor,"(Number x, Number y)",Number,Returns the largest multiple of y which is less than or equal to x.,"Floor(3.5, 2) returns 2" +,,,,,"Floor(-3.5, 2) returns -4" +Rounding and Truncating,Floor,"(Duration x, Duration y)",Duration,Returns the largest multiple of the duration y which is less than or equal to the duration x.,"Floor(Days(3.5), Days(2)) returns Days(2)" +Numbers,Frac,(Number n),Number,"Returns the fractional part of the Number n. The result is the same sign as n. For any Number n, Int(n) + Frac(n) is the same as n.",Frac(3.4) returns 0.4 +,,,,,Frac(-2.3) returns -0.3 +Special,GetFieldProperty,"(Number fid, Text prop)","","Returns the value of the field property prop, in the field fid, in the current table.  The properties currently supported are ""currencyFormat"", ""currencySymbol"", ""fieldType"", ""maxLength"", ""required"", and ""unique"".","GetFieldProperty(15,""required"") returns true if field 15 in the current table is required, false if not." +Queries,GetFieldValues,"(RecordList l, Number f)",Text Collection,Returns values of fields with field id f from records in l. Use in Formula- Multi-select Text field.,"GetFieldValues(GetRecord(1), 3), returns [“1”]." +Queries,GetRecord,(Number r),Record Collection,Returns record with record id r from the current table. Use this formula to obtain a record list for GetFieldValues.,"GetRecord(1) returns a list of records, in this case containing a single record with Record ID# 1." +Queries,GetRecord,"(Number r, Text t)",Record Collection,Returns record with record id r from table with dbid t. Use this formula to obtain a record list for GetFieldValues.,"GetRecord (1, “bck7gp3q2”) returns record with Record ID# 1 from table with dbid “bck7gp3q2”." +Queries,GetRecords,(Text q),Record Collection,Returns records matching the criteria set by query q from current table. Use this formula to obtain record list for GetFieldValues.,GetRecords (“{3.GT.0}”) returns all records in current table for which the field with id 3 has value greater than 0. +,,,,, +,,,,Note that queries within this formula use API query syntax., +Queries,GetRecords,"(Text q, Text t)",Record Collection,Returns records matching the criteria set by query q from table with dbid t. Use this formula to obtain record list for GetFieldValues.,"GetRecords(""{3.GT.0}"", ""bck7gp3q2"") returns all records from table with dbid “bck7gp3q2” for which the field with id 3 has value greater than 0." +,,,,, +,,,,Note that queries within this formula use API query syntax., +,,,,, +TimeOfDay,Hour,(TimeOfDay t),Number,Returns the hour part of the argument t. The hour is in the range 0 to 23.,"Hour(ToTimeOfDay(""3:04pm"")) returns 15" +Durations,Hours,(Number n),Duration,"Returns a Duration representing n hours. This function takes a number and converts it into a Duration type value, expressed in hours.",Hours(4) returns a 4 hour duration +,,,,,[Intake Time]+Hours(2) returns the time of day from the Intake Time field plus two hours. +,,,,,"Hours([Test Length in Hours]) converts the numeric value in the Test Length in Hours field into a duration expressed in hours. The number doesn't change, just the data type." +Type Conversion,HTMLToText,(Text t),Text,Takes HTML text input and returns plain text content.,"HTMLToText(""Foo"") returns ""Foo""" +Special,If,"(Boolean condition1, result1, ..., else-result)",,"If condition1 is true, returns result1, otherwise returns else-result. You can include additional condition/result pairs before the final else-result (as in the first example). QuickBase evaluates the conditions in sequence until one is found to be true, and then the corresponding result is returned. The else-result is optional. If omitted, QuickBase assumes it's null (or empty - a blank). All conditions must be of type Boolean (return a true or a false). Results may be of any type, but they must all be the same type.","If([Grade]=""A"", 100, [Grade]=""B"", 90)" +,,,,, +,,,,,"This formula says: if the value in the Grade field is A, then return 100. If the value in the Grade field is B, then return 90." +,,,,, +,,,,,"IF([Order Complete]=TRUE, [Subtotal] + [Tax], null)" +,,,,, +,,,,,"This formula says: If the Order Complete checkbox is on, then add the value in the subtotal field to the value in the tax field and display it. If not, then leave the field empty (or null)." +,,,,, +Special,Includes,"(UserList ul, UserList ul1,UserList ul2 ..)",Boolean,"This function takes 2 or more list-user field types as arguments and returns true if the contents of all the the arguments together, except for the first, are included in the contents of first argument; false otherwise.","Includes ([Assigned To] , [Manager], [Employee])" +,,,,, +,,,,,This will return true if all the users in the Manager field and Employee field are selected for Assigned To field. +Numbers,Int,(Number n),Number,"Returns the integer part of Number n. Note that if n is a negative fraction, the result is closer to 0 than n is (compare to function Floor).",Int(3.6) returns 3 +,,,,,Int(3) returns 3 +,,,,,Int(-3.6) returns -3 +Dates,IsLeapDay,(Date d),Boolean,Returns true if d is February 29.,IsLeapDay([Date Due]) returns true if the value in the Date Due field falls on a leap day like 2/29/08. +Dates,IsLeapYear,(Date d),Boolean,Returns true if the date d falls in a leap year.,"IsLeapYear([Release Date]) returns true if the date in the Release Date field falls in a leap year like 2008. If it occurs in a non leap year, like 2007, the result is false." +Dates,IsLeapYear,(Number y),Boolean,Returns true if the year y is a leap year.,IsLeapYear(2007) returns false. +,,,,,IsLeapYear(2008) returns true. +Null Handling,IsNull,( x),Boolean,"Null means that a field's value is undefined. In other words, no one has entered any data in that particular field. It's empty. Its value is null. The result of this function is true if x is null, otherwise false. The argument x may be of any data type (except text or boolean).",IsNull([Start Date]) returns true if the field named Start Date is undefined or empty. +,,,,,IsNull(3.4) returns false +Special,IsUserEmail,(Text x),Boolean,Returns true if x is the email address of the current user.,"IsUserEmail(""john_smith@example.com"") would return true if John Smith were accessing the table containing the formula field." +Dates,IsWeekday,(Date d),Boolean,"Returns true if d is a weekday, otherwise false.","IsWeekday([Deliver On]) returns true if the date in the Deliver On field is a weekday. If not, the result is false." +,,,,, +,,,,,"IsWeekday(ToDate(""6/20/2003"")) returns true" +Dates,LastDayOfMonth,(Date d),Date,Returns the last day of the month in which the date falls.,LastDayOfMonth([Service Date]) returns the date of the last day of the month in which the Service Date occurs. +,,,,, +,,,,,"LastDayOfMonth(ToDate(""2/12/2008"")) returns " +,,,,,2/29/2008 +Dates,LastDayOfPeriod,"(Date d, Duration p, Date r)",Date,"Returns the last day of the period in which the date falls. The cycle of periods is defined by the given Duration p, repeated in sequence starting at the given reference date r. If the period p is not a whole number of days, the fractional part is ignored.","LastDayOfPeriod([Payment Date], [Quarter Length in Days], [Fiscal Year Start Date]) returns the last day of the quarter in which the Payment Date falls." +Dates,LastDayOfWeek,(Date d),Date,Returns the last day (Saturday) of the week in which the date falls.,LastDayOfWeek([Date A]) + Days(1) returns the Sunday date of the week that follows the one in which Date A falls. +Dates,LastDayOfYear,(Date d),Date,Returns the last day of the year in which the date falls.,LastDayOfYear([Registration Date]) returns the last day of the year in which the Registration Date falls. +,,,,, +,,,,,"LastDayOfYear(Todate(""August 4, 2009"")) returns 12-31-2009" +Text,Left,"(Text t, Number n)",Text,Returns the leftmost n characters from the Text argument t.,"Left(""abcd"", 2) returns ""ab""" +Text,Left,"(Text t, Text d)",Text,"Returns the left part of a text value up to but not including the first occurrence of a delimiter character. The first argument, t, is the value to be searched. The second argument, d, is a text value containing all the possible delimiter characters.","Left(""abc/def"","";/,"") returns ""abc""" +,,,,,"Left(""Michael Smith"", "" "") returns ""Michael""" +Text,Length,(Text t),Number,Returns the number of characters in t.,"Length(""abc"") returns 3" +Text,List,"(Text d, Text t1, Text t2, ...)",Text,"Concatenates (strings together) all arguments starting with the second argument, using the first argument as the delimiter between them. If one of the arguments is blank, it and the corresponding delimiter are omitted.","List(""-"", ""a"", ""b"", ""d"") returns ""a-b-d""" +,,,,, +,,,,,"List("", "", ""a"", ""b"", """", ""d"") returns ""a, b, d""" +,,,,, +,,,,,"List("", "", [Last Name], [First Name]) returns ""Last Name, First Name"" if both fields are not empty, returns ""Last Name"" if [First Name] is empty, and returns ""First Name"" if [Last Name] is empty." +,,,,, +,,,,,"List(""\n"", ""Name"", ""Address Line 1"", """", List("", "", ""City"", ""State""), ""Zip"") returns" +,,,,,"""Name" +,,,,,Address Line 1 +,,,,,"City, State" +,,,,,"Zip""" +Numbers,Ln,(Number n),Number,Returns the natural (base e) logarithm of n.,Exp(Ln(72)) returns 72 +Numbers,Log,(Number n),Number,Returns the base 10 logarithm of n.,Log(100) returns 2 +,,,,,10 ^ Log(72) returns 72 +Text,Lower,(Text t),Text,Returns t converted to lower case.,"Lower(""ABC"") returns ""abc""" +Aggregation,Max,"( x, y, ...)",,"This function can take 2 or more arguments of any data type, as long as they are all the same type. The result is the same data type as the arguments. Null values are ignored. For Numbers, returns the argument that is greatest. For Text, returns the argument that sorts last alphabetically. For Durations, returns the argument that is longest. For Dates returns the argument that is latest. For Date/Time, returns the argument that is latest. For TimeOfDays, returns the argument that is latest. For Booleans returns the argument that is largest, treating false as less than true.","Max (Days(2), Weeks(1)) returns 1 week" +,,,,,"Max (10, 20, 30, 40) returns 40" +,,,,,"Max (10, null, 30) returns 30" +,,,,,"Max (null, null, null) returns null" +Text,Mid,"(Text t, Number p, Number n)",Text,"Returns n characters from the middle of t, starting at position p. The first character is position 1.","Mid(""abcd"", 2, 3) returns ""bcd""" +,,,,,"Mid(""abcd"", 4, 4) returns ""d""" +Aggregation,Min,"( x, y, ...)",,"This function can take 2 or more arguments of any data type, as long as they are all the same type. The result is the same data type as the arguments. Null values are ignored. For Numbers, returns the argument that is least. For Text, returns the argument that sorts first alphabetically. For Durations, returns the argument that is shortest. For Dates returns the argument that is earliest. For Date/Time, returns the argument that is earliest. For TimeOfDays, returns the argument that is earliest. For Booleans returns the argument that is smallest, treating false as less than true.","Min (10, 20, 30, 40) returns 10" +,,,,,"Min ([Date1], [Date2]) returns whichever date field's value is earlier" +,,,,,"Min (Days(2), Weeks(1)) returns 2 days" +,,,,,"in (10, null, 30) returns 10" +,,,,,"Min (null, null, null) returns null" +TimeOfDay,Minute,(TimeOfDay t),Number,Returns the minute part of the argument t. The minute is in the range 0 to 59.,"Minute(ToTimeOfDay(""3:04pm"")) returns 4" +Durations,Minutes,(Number n),Duration,"Returns a Duration representing n minutes. This function takes a number and converts it into a Duration type value, expressed in minutes.",minutes(42) returns a duration of 42 minutes. +,,,,,"Minutes([Test Length in Minutes]) converts the numeric value in the Test Length in Minutes field into a duration expressed in minutes. The number doesn't change, just the data type." +,,,,,[Start Time] + minutes(90) returns the time of day that 90 minutes after the time of day in the Start Time field. +Numbers,Mod,"(Number n, Number m)",Number,"Returns n modulo m. Mod implements 'clock' arithmetic; it models movement around a clock that is labeled with the numbers 0 to m-1. To get the result, just count n times around the clock. (Mod is the same as Rem for positive numbers, but different for negative numbers.) You might use this function to auto-assign tasks to staff members. For details, please read this article","Mod(7,3) returns 1" +,,,,,"Mod(-7,3) returns 2" +,,,,,"Mod(7,-3) returns -1" +,,,,,"Mod(-7,-3) returns -2" +,,,,, +,,,,,Sample auto-assign formula: +,,,,, +,,,,,"If (Mod([Record ID#],ToNumber([number_of_reps])) = 0, ToUser(""jsmith"")," +,,,,,"Mod([Record ID#],ToNumber([number_of_reps])) = 1, ToUser(""bboop"")," +,,,,,"Mod([Record ID#],ToNumber([number_of_reps])) = 2, ToUser(""ppeabody""))" +Durations,Mod,"(Duration n, Duration m)",Duration,"Returns n modulo m. (Mod is the same as Rem for positive numbers, but different for negative numbers.)","Mod(Days(4), Days(3)) =  Days(1)" +,,,,,"Mod(Days(-4), Days(3)) =  Days(2)" +,,,,,"Mod(Days(4), Days(-3)) = Days(-1)" +,,,,,"Mod(Days(-4), Days(-3)) = Days(-2)" +Dates,Month,(Date d),Number,Returns the month number of the Date d. January is month 1.,Month([Start Date]) returns the number of the month within the date that appears in the Start Date field. +,,,,, +,,,,,"Month(ToDate(""Jan 10, 2000"") returns 1" +TimeOfDay,MSecond,(TimeOfDay t),Number,Returns the millisecond part of the argument t. The millisecond is in the range 0 to 999.,"MSecond(ToTimeOfDay(""3:04:01.344 pm"")) returns 344" +Durations,MSeconds,(Number n),Duration,"Returns a Duration representing n milliseconds. This function takes a number and converts it into a Duration type value, expressed in milliseconds.",Mseconds(250) returns a duration of 250 seconds. +,,,,,"Mseconds([Shutter Time]) converts the numeric value in the Shutter Time field into a duration expressed in milliseconds. The number doesn't change, just the data type." +Dates,NameOfMonth,"(Date d), Text d",Text,Returns the name of the month containing the given date. Optionally enter 'short' in the second parameter to return the abbreviation of the month.,"NameOfMonth(ToDate(""2/20/20"")) returns ""February""" +,,,,,"NameOfMonth(ToDate(""2/20/20""), short) returns ""Feb""" +Date/Times,NameOfMonth,(Date/Time dt),Text,"Returns the name of the month containing the given Date/Time.  Note that this is evaluated in the app's local time zone, not GMT.","If today is January 20, 2020, NameOfMonth(Now()) returns ""January""" +,,,,Optionally enter 'short' in the second parameter to return the abbreviation of the month.,"NameOfMonth(Now(), short) returns ""Jan""" +Numbers,NameOfMonth,(Number n),Text,Returns the name of the month whose number is n.  Ensure n is a whole number between 1 and 12. Optionally enter 'short' in the second parameter to return the abbreviation of the month.,"NameOfMonth(12) returns ""December""" +,,,,,"NameOfMonth(12, short) returns ""Dec""" +Dates,NextDayOfWeek,"(Date d, Number n)",Date,"Returns the first day after the given date d that falls on the weekday n. n is a number from 0 to 6 with Sunday being 0, Monday being 1, Tuesday being 2, and so on.","NextDayOfWeek([Date Submitted], 2) returns the first Tuesday that follows the Date in the Date Submitted field." +Unary Operators (act on single values),not,Boolean b,Boolean,"Returns the logical negation of b (if b is true, returns false, otherwise returns true).",(not true) returns false +,,,,,(not not false) returns false +,,,,,"not isnull([Finish Date]) returns true if the Finish Date field has content--in other words, it's not null, or empty. (Read more about nulls at: https://www.quickbase.com/help/using_formulas_in_quickbase.html#null)" +Text,NotLeft,"(Text t, Number n)",Text,Returns what remains after excluding the leftmost n characters from the Text argument t.,"NotLeft(""abcde"", 2) returns ""cde""" +Text,NotLeft,"(Text t, Text d)",Text,"Returns what remains after excluding the left part of a text value up to and including the first occurrence of a delimiter character. The first argument, t, is the value to be searched. The second argument, d, is a text value containing all the possible delimiter characters. If space is included in the delimiter list it is handled specially. It acts as a delimiter, but contiguous spaces surrounding a delimiter are ignored rather than each acting as a separate delimiter.","NotLeft(""abc/ def/ghi"","" ;/,"") returns ""def/ghi""" +,,,,,"NotLeft(""Michael    Wissner"", "" "") returns ""Wissner""" +Text,NotRight,"(Text t, Number n)",Text,Returns what remains after excluding the rightmost n characters from the Text argument t.,"NotRight(""ABCDE"", 2) returns ""ABC""" +Text,NotRight,"(Text t, Text d)",Text,"Returns what remains after excluding the right part of a text value starting at the last occurrence of a delimiter character. The first argument, t, is the value to be searched. The second argument, d, is a text value containing all the possible delimiter characters. If space is included in the delimiter list it is handled specially. It acts as a delimiter, but contiguous spaces surrounding a delimiter are ignored rather than each acting as a separate delimiter.","NotRight(""abc/ def"","" ;/,"") returns ""abc""" +,,,,,"NotRight(""Michael   Wissner"", "" "") returns ""Michael""" +Date/Times,Now,(),Date/Time,Returns a Date / Time value representing the current moment.,now() - [Date Created] returns the duration between the current moment and the date the record was created. +Null Handling,Nz,(Number x),Number,"This function returns x if x is not null. If it is null, it returns 0 instead. An undefined or empty field is ""null."" Null values don't work in numeric calculations, which is where the Nz() function comes in handy. When Nz() finds a null, it sees it as a zero. So, if you want to perform calculations on a field that may include a null, use the Nz function.",Nz(4) returns 4 +,,,,,Nz(0) returns 0 +,,,,,Nz(null) returns 0 +,,,,,"Nz([Mon]) + Nz([Tues] + Nz([Wed]) + Nz([Thurs]) + Nz([Fri]) returns the total of all numbers found in these day of the week fields. If a field is empty, Nz() reads it as a zero." +Null Handling,Nz,(Duration d),Duration,"This function returns d if d is not null. If it is null, it returns a 0-length duration instead. An undefined or empty field is ""null."" Null values don't work in calculations, which is where the Nz() function comes in handy. When Nz() finds a null, it sees it as a zero. So, if you want to perform calculations on a field that may include a null, use the Nz function.",Nz([Actual Duration]) + Nz([Actual Duration 1]) +  Nz([Actual Duration 2]) returns the totals of values in all these duration fields. +Null Handling,Nz,"(Number x, Number y)",Number,"This function returns x if x is not null. If it is null, it returns the alternate value y instead. Null means that a field's value is undefined. In other words, no one has entered any data in that particular field. It's empty. Its value is null.","Nz(34, 100) returns 34" +,,,,,"Nz(null, 100) returns 100" +,,,,,"Nz([Final Sales Price], [Price Quote]) returns the value in the Final Sales Price field, if it exists. If Final Sales Price is empty (null), then this example returns the value in the Price Quote field." +Null Handling,Nz,"(Duration x, Duration y)",Duration,"This function returns x if x is not null. If it is null, it returns the alternate value y instead. Null means that a field's value is undefined. In other words, no one has entered any data in that particular field. It's empty. Its value is null.","Nz([Length of Project], [Estimated Length of Project] returns the value in the Length of Project field, if it exists. If there is no value in the Length of Project field, this formula returns the value in the Estimated Length of Project field." +Null Handling,Nz,"(Date x, Date y)",Date,"This function returns x if x is not null. If it is null, it returns the alternate value y instead. Null means that a field's value is undefined. In other words, no one has entered any data in that particular field. It's empty. Its value is null.","Nz([Actual Finish Date], [Estimated Finish Date]) returns the value in the Actual Finish Date field, if it exists. If the Actual Finish Date field is empty, this formula returns the date in the Estimated Finish Date field." +Null Handling,Nz,"(Date/Time x, Date/Time y)",Date/Time,"This function returns x if x is not null. If it is null, it returns the alternate value y instead. Null means that a field's value is undefined. In other words, no one has entered any data in that particular field. It's empty. Its value is null.","Nz([Timestamp of Request], [Date Created]) returns the value from the Timestamp of Request field, if it exists. If there is no value in that field, this formula returns the value from the Date Created field instead." +Null Handling,Nz,"(TimeOfDay x, TimeOfDay y)",TimeOfDay,"This function returns x if x is not null. If it is null, it returns the alternate value y instead. Null means that a field's value is undefined. In other words, no one has entered any data in that particular field. It's empty. Its value is null.","Nz([Finish Time], [Closing Time]) returns the value in the Finish Time field, if it exists. If there is no value in the Finish Time field, this formula returns the value in the Closing Time field." +Binary Operators (act on multiple values),or,"Boolean a, Boolean b",Boolean,"Returns true if either a or b is true, otherwise returns false.",(true or false) returns true +Text,PadLeft,"(Text textToPad, Number targetLength, Text paddingText)",Text,"Extends text to a target length by adding padding to the left of the text. If the text is already the target length or longer, then there's no change. Otherwise, the padding text is added as necessary to reach the target length. NOTE: This function pads up to a limit of 50 characters.","PadLeft(""123"", 5, ""0"") returns ""00123""" +Text,PadRight,"(Text textToPad, Number targetLength, Text paddingText)",Text,"Extends text to a target length by adding padding to the right of the text. If the text is already the target length or longer, then there's no change. Otherwise, the padding text is added as necessary to reach the target length. NOTE: This function pads up to a limit of 50 characters.","PadRight(""123"", 5, ""**"") returns ""123**""" +Text,Part,"(Text t, Number p, Text d)",Text,"Returns the specified part of a text value. The parts are separated by the occurrence of any delimiter character. The first argument, t, is the value to be searched. The second argument, p, is the position of the part in the argument t. The first part starting on the left is position 1. Negative part numbers can be used to start from the right. The third argument, d, is a text value containing all the possible delimiter characters. If space is included in the delimiter list it is handled specially. It acts as a delimiter, but contiguous spaces surrounding a delimiter are ignored rather than each acting as a separate delimiter.","Part(""hh:mm:ss"",1,"":"") returns ""hh""" +,,,,,"Part(""hh:mm:ss"",3,"":"") returns ""ss""" +,,,,,"Part(""hh:mm:ss"",-1,"":"") returns ""ss""" +,,,,,"Part(""hh:mm"",3,"":"") returns """"" +,,,,,"Part(""abc, def,ghi"",2,"" ,"") returns ""def""" +,,,,,"Part([Append],2,""["") returns all text from the Append field that lives between the first [ character and the second [ character. This example shows how to extract only the most recent entry from an append field." +Dates,PrevDayOfWeek,"(Date d, Number n)",Date,"Returns the last day before the given date d that falls on the weekday n. n is a number from 0 to 6 with Sunday being 0, Monday being 1, Tuesday being 2, and so on.","PrevDayOfWeek([Date Due], 5) returns the date of the last Friday that occurse before the Date Due." +Numbers,PV,"(Number rate, Number nskip, Number amt)",Number,Calculates the Present Value of a future payment. Rate is the discount rate for one time period. Nskip is the number of time periods before the payment occurs. Amt is the amount of the payment.,"PV(.10, 2, 121) returns 100" +Numbers,PV,"(Number rate, Number nskip, Number amt, Number npay)",Number,"Calculates the Present Value of a series of future payments. Rate is the discount rate for one time period. Nskip is the number of time periods before the first payment occurs. Amt is the amount of each payment. Npay is the number of payments in the series, spaced one time period apart.","PV(.10, 1, 100, 3) returns 248.69" +Encoding Functions,QB32Decode,(Text t),Number,Decodes text back to an integer using a base32 encoding specific to Quick Base.,"QB32Decode(""bpcwsmnz6"") returns 1549023949564" +Encoding Functions,QB32Encode,(Number p),Text,Encodes an integer as text using a base32 encoding specific to Quick Base,"QB32Encode(1549023949564) returns ""bpcwsmnz6""" +Numbers,Rem,"(Number n, Number d)",Number,"Returns a number that is the remainder after n is divided by d an integer number of times. (Mod is the same as Rem for positive numbers, but different for negative numbers.)","Rem(7,3) returns 1" +,,,,,"Rem(-7,3) returns -1" +,,,,,"Rem(7,-3) returns 1" +,,,,,"Rem(-7,-3) returns -1" +Durations,Rem,"(Duration n, Duration d)",Duration,"Returns a Duration that is the remainder after n is divided by d an integer number of times. (Mod is the same as Rem for positive numbers, but different for negative numbers.)","Rem(Days(4), Days(3)) =  Days(1)" +,,,,,"Rem(Days(-4), Days(3)) = Days(-1)" +,,,,,"Rem(Days(4), Days(-3)) =  Days(1)" +,,,,,"Rem(Days(-4), Days(-3)) = Days(-1)" +Text,RemoveNewlineCharacters,(Text t),Text,"Finds all instances of newline characters ""\r"" and ""\n"" and replaces them with a space. Consecutive newline characters are treated as one occurrence.","RemoveNewlineCharacters(""Hello\r\n\r\nWorld"") returns ""Hello World""" +Text,Right,"(Text t, Number n)",Text,Returns the rightmost n characters from the Text argument t.,"Right(""ABCD"", 2) returns ""CD""" +Text,Right,"(Text t, Text d)",Text,"Returns the right part of a text value starting at the character after the last occurrence of a delimiter character. The first argument, t, is the value to be searched. The second argument, d, is a text value containing all the possible delimiter characters.","Right(""abc/def"","";/,"") returns ""def""" +,,,,,"Right(""Michael Wissner"", "" "") returns ""Wissner""" +Rounding and Truncating,Round,(Number x),Number,Returns the nearest integer to the number x. The fraction .5 rounds up to the next greater integer.,Round(3.2) = 3 +,,,,,Round(3.5) = 4 +,,,,,Round(3.7) = 4 +,,,,,Round(-3.4) = -3 +,,,,,Round(-3.5) = -3 +,,,,,Round(-3.7) = -4 +Rounding and Truncating,Round,"(Number x, Number y)",Number,"Returns the multiple of y which is nearest to x. You may notice small discrepancies when you use this function with floating point numbers. For example, Round(37.785,0.01) returns 37.78 instead of 37.79. This is not a QuickBase-specific issue; the discrepancies happen because some floating point numbers cannot be represented exactly in the binary format required by computers and are instead approximated.","Round(3.12345, .01) returns 3.12" +,,,,,"Round(3.6, 2) returns 4" +,,,,,"Round(-3.6, 2) returns -4" +Rounding and Truncating,Round,"(Duration x, Duration y)",Duration,Returns the multiple of the duration y which is nearest the duration x.,"Round(Days(3.5), Days(2)) returns Days(4)" +Text,SearchAndReplace,"(Text textToSearch, Text searchText, Text replacementText)",Text,Replaces all occurrences of a given search text with the replacement text. Search is case sensitive.,"SearchAndReplace(""John Smith"", ""John"", ""Jane"") returns ""Jane Smith""" +TimeOfDay,Second,(TimeOfDay t),Number,Returns the second part of the argument t. The second is in the range 0 to 59.,"Second(ToTimeOfDay(""3:04 pm"")) returns 0" +Durations,Seconds,(Number n),Duration,"Returns a Duration representing n seconds. This function takes a number and converts it into a Duration type value, expressed in seconds.",seconds(120) returns a duration of 120 seconds. +,,,,,"seconds([100yd Dash Finish Time]) converts the numeric value in the 100yd Dash Finish Time field into a duration expressed in seconds. The number doesn't change, just the data type." +,,,,,[Start Time] + seconds(10) returns the time of day that's 10 seconds after the time of day in the Start Time field. +Queries,Size,(List l),Number,"Returns the number of items in a TextList, UserList, or RecordList.",Size([Assigned To]) returns the number of items in the ?Assigned to? field for a given record. +TextList,Split,(Text textToSplit),Text,"Converts text string, in format of items list with semicolon separator, to list structure. Each item can be processed separately. Empty values and blank values are ignored. Spaces at the beginning and at the end of resulting items are removed.","Example: Split("" item-0 ;;item-1; ;item-2"") returns [""item-0"", ""item-1"", ""item-2""]" +TextList,Split,"(Text textToSplit, Text delimiter)",Text,"Converts text string, in format of items list with custom separator character, to list structure. Each item can be processed separately. Empty values and blank values are ignored. Spaces at the beginning and at the end of resulting items are removed.","Example: Split("" item-0 ==item-1= =item-2"", ""="") returns [""item-0"", ""item-1"", ""item-2""]" +Numbers,Sqrt,(Number n),Number,Returns the square root of n.,Sqrt(9) returns 3 +,,,,,Sqrt(16) returns 4 +Aggregation,Sum,"(Number n, ...)",Number,Returns the sum of the non-null arguments.,"Sum(12.50, 0.5, null, 3) returns 16" +Aggregation,Sum,"(Duration d, ...)",Duration,Returns the sum of the non-null arguments.,"Sum(Days(1), Weeks(2), Days(2)) returns 17 days" +Queries,SumValues,"(RecordList l, Number f)",Number,Returns the sum of values of fields with id f from record list l. f should be an id of a numeric field.,"SumValues(GetRecords(?{3.GT.0}?), 3) returns sum of all records ids in current table. (Record ID# is the field with id 3 and records ids are greater than 0.)" +Type Conversion,ToBoolean,(Text x),Boolean,"Converts the values ""1"", ""true"" or ""yes"" to true, other values to false. Case is ignored.","ToBoolean(""Yes"") returns true" +,,,,,"ToBoolean(""0"") returns false" +Type Conversion,ToBoolean,(Number n),Boolean,"Returns true if the Number n is a non-zero value, otherwise returns false.",ToBoolean(1) returns true +,,,,,ToBoolean(0) returns false +,,,,,ToBoolean(null) returns false +Type Conversion,ToDate,(Text x),Date,"Converts the text value x into a Date. x can be several of the popular date formats, including ""January 30, 2000"", ""1/30/00"", ""2000/1/30"", ""1-30-2000""","ToDate(""Jan 30, 2000"") takes the text ""Jan 30, 2000"" and returns it as a date type value." +Type Conversion,ToDate,(Date/Time x),Date,"Date/Time type fields have a date, time and time zone associated with them, while Dates do not have time and time zone. This function converts the Date/Time x into a Date by returning the Date, in the local time zone, in which Date/Time x falls.",ToDate([Date Modified]) returns the Date the record was last modified +Dates,Today,(),Date,Returns the current date in the local time zone.,Today() - [Estimated Finish Date] calculates the duration since the Estimated Finish Date occurred. +Durations,ToDays,(Duration d),Number,"Takes a Duration d, and returns the number of days contained in it.",ToDays([Start Date] - Today()) returns the number of days until the Start date. +,,,,, +,,,,,ToDays(Weeks(2)) returns 14 +Type Conversion,ToFormattedText,"(Date d, Text f)",Text,Returns a Text value which is the formatted text version of the date specified. The Text argument to the function specifies the format. Choose one of: MMDDYYYY MMDDYY DDMMYYYY DDMMYY YYYYMMDD,"ToFormattedText(Date(2000,01,30),""MMDDYYYY"")  returns ""01-30-2013"" " +,,,,,"ToFormattedText(Date(2000,01,30),""MMDDYY"")  returns ""01-30-13"" " +,,,,,"ToFormattedText(Date(2000,01,30),""DDMMYYYY"")  returns ""30-01-2013"" " +,,,,,"ToFormattedText(Date(2000,01,30),""DDMMYY"")  returns ""30-01-13"" " +,,,,,"ToFormattedText(Date(2000,01,30),""YYYYMMDD"")  returns ""2013-01-30""" +Type Conversion,ToFormattedText,"(Date/Time t, Text f)",Text,Returns a Text value which is the formatted text version of the date/time specified. The Text argument to the function specifies the format. Choose one of: MMDDYYYY MMDDYY DDMMYYYY DDMMYY YYYYMMDD,"ToFormattedText([Date Created],""MMDDYYYY"")  returns ""01-30-2013 10:34 PM"" " +,,,,,"ToFormattedText([Date Created],""MMDDYY"")  returns ""01-30-13 10:34 PM"" " +,,,,,"ToFormattedText([Date Created],""DDMMYYYY"")  returns ""30-01-2013 10:34 PM"" " +,,,,,"ToFormattedText([Date Created],""DDMMYY"")  returns ""30-01-13 10:34 PM"" " +,,,,,"ToFormattedText([Date Created],""YYYYMMDD"")  returns ""2013-01-30 10:34 PM""" +Type Conversion,ToFormattedText,"(Number n, Text f)",Text,"Returns a Text value which is the formatted text version of the number specified. The Text argument to the function specifies the format. Choose one of: none_dot - returns the number formatted like 12345678.85 comma_dot - returns the number formatted like 12,345,678.85 comma_dot_2 - returns the number formatted like 1,23,45,678.85 none_comma - returns the number formatted like 12345678,85 dot_comma - returns the number formatted like 12.345.678,85 dot_comma_2 - returns the number formatted like 1.23.45.678,85","ToFormattedText(2394729834.85, ""comma_dot"") returns 2,394,729,834.85" +Type Conversion,ToFormattedText,"(Duration d, Text f)",Text,"Returns a Text value containing the formatted print representation of the Duration d, using the format f. The Text argument to the function specifies the format. Choose one of: HHMM HHMMSS MMSS MM MSECONDS SECONDS MINUTES HOURS DAYS WEEKS SMART","ToFormattedText([Duration],""HHMM"")  returns ""01:12"" " +,,,,,"ToFormattedText([Duration],""HHMMSS"")  returns ""01:02:03"" " +,,,,,"ToFormattedText([Duration],""MMSS"")  returns "":02:03"" " +,,,,,"ToFormattedText([Duration],""MM"")  returns "":02"" " +,,,,,"ToFormattedText([Duration],""MSECONDS"")  returns ""83750000"" " +,,,,,"ToFormattedText([Duration],""SECONDS"")  returns ""8375"" " +,,,,,"ToFormattedText([Duration],""MINUTES"")  returns ""25.55"" " +,,,,,"ToFormattedText([Duration],""HOURS"")  returns ""25.35"" " +,,,,,"ToFormattedText([Duration],""DAYS"")  returns ""1.53"" " +,,,,,"ToFormattedText([Duration],""WEEKS"")  returns ""0.1235"" " +,,,,,"ToFormattedText([Duration],""SMART"")  returns ""23.434 hours""" +Type Conversion,ToFormattedText,"(Number n, Text f, Number c)",Text,"Returns a Text value containing the formatted print representation of the number n, using the format f, with separators starting after number c digits.  Valid values for c are 3 or 4; if it is 3, separators will be shown starting after 3 digits instead of after 4.","ToFormattedText(1234.56,""comma_dot"", 3) returns ""4,567.89""" +,,,,,"ToFormattedText(1234.56,""comma_dot"", 4) returns ""4567.89""" +,,,,,"ToFormattedText(1234567.89,""comma_dot_2"",3) returns ""12,34,567.89""" +,,,,,"ToFormattedText(1234.56,""dot_comma"", 3) returns ""4.567,89""" +,,,,,"ToFormattedText(1234.56,""dot_comma"", 4) returns ""4567,89""" +,,,,,"ToFormattedText(1234567.89,""dot_comma_2"",3) returns ""12.34.567,89""" +Durations,ToHours,(Duration d),Number,"Takes a Duration d, and returns the number of hours contained in it.",ToHours(Days(2)) returns 48 +Durations,ToMinutes,(Duration d),Number,"Takes a Duration d, and returns the number of minutes contained in it.",ToMinutes(Hours(2)) returns 120 +Durations,ToMSeconds,(Duration d),Number,"Takes a Duration d, and returns the number of milliseconds contained in it.",ToMSeconds(Seconds(2)) returns 2000 +Type Conversion,ToNumber,(Text x),Number,Returns the number represented by the Text value x.,"ToNumber(""-12.3"") returns -12.3" +,,,,,"ToNumber("""") returns null" +Type Conversion,ToNumber,(Boolean b),Number,"Returns 0 if b is false, 1 if b is true.",ToNumber(false) returns 0 +,,,,,ToNumber(true) returns 1 +Durations,ToSeconds,(Duration d),Number,"Takes a Duration d, and returns the number of seconds contained in it.",ToSeconds(Minutes(2)) returns 120 +Type Conversion,ToText,( x),Text,Returns a Text value containing the print representation of the argument x.,"ToText(3.4) returns ""3.4""" +,,,,,"ToText(null) returns """"" +,,,,,"ToText(true) returns ""1""" +,,,,,"ToText(Date(2000,12,16)) returns ""12-16-2000""" +,,,,,"ToText(ToTimeOfDay(""4pm"")) returns ""4:00 pm""" +,,,,,"ToText(Hours(2)+Minutes(20)) returns ""2:20""" +,,,,,"ToText([Date Created]) returns ""12-15-2000 10:34 PM""" +,,,,, +,,,,,ToText([Record Owner]) returns the user name or email address of the user who appears in the Record Owner field. +,,,,, +,,,,,"ToText([AssignedTo]), where AssignedTo is a list-user field type, returns a semi-colon separated list of the user names or email addresses of the users  who appear in the AssignedTo field." +Type Conversion,ToText,"(Number x, Text t)",Text,Returns a Text value containing the print representation of the argument x. Includes a second argument indicating how many digits to include before truncating or rounding.,"ToText(355/113, ""full"") returns 3.1415929203539825" +,,,,(Default) Legacy - up to 8 digits,"ToText(355/113, ""legacy"") returns 3.1415929" +,,,,Full - exposes the most digits possible, +Type Conversion,ToTimeOfDay,(Text x),TimeOfDay,"Converts the text value x into a TimeOfDay. x can be several of the popular time formats, including ""3 pm"", ""3:04 am"", ""22:00"", ""2:03:29 am"", ""12:03:29.345"".","ToTimeofDay(""16:32"") returns the time 4:32pm" +Type Conversion,ToTimeOfDay,(Date/Time t),TimeOfDay,Date/Time fields display the date and time. This function returns the TimeOfDay on which the Date/Time t falls in the local time zone.,ToTimeOfDay([Last Modified]) +Type Conversion,ToTimestamp,(Date d),Date/Time,Returns a Date / Time value which is 12:00 am of the given Date d in the local time zone (midnight at the beginning of the Date).,"ToTimestamp(Date(2000,7,4)) returns midnight on July 4, 2000." +Type Conversion,ToTimestamp,"(Date d, TimeOfDay t)",Date/Time,"Returns a Date / Time value which is at the given TimeOfDay t, on the given Date d in the local time zone","ToTimestamp([Start Date], [Start Time])" +Special,ToUser,(Text t),User,"Converts a user name, email address, or user ID to a user value. A ""user"" is an individual with whom you've shared your application. You'd translate something like an e-mail address into a user value so that QuickBase recognizes the user. When you do so, you can take advantage of user fields to design permissions and/or views. For example, show a user only those tasks that have been assigned to her.",ToUser([Email Address]) takes the values in the Email Address field and returns their corresponding user values. +,,,,,"ToUser(""jsmith"") returns the user with the user name ""jsmith"". " +,,,,,"ToUser(""jsmith@example.com"") returns the user possessing that e-mail address.  " +,,,,,ToUser(123456.abcd) returns the user with that user ID. +Special,ToUserList,(User u ..),UserList,Concatenates the contents of one or more user type fields into a user list field type.,"ToUserList([Sales Manager], [Business Manager]) takes the values in the Sales Manager field and Business Manager field and returns the combined value into another field." +,,,,, +,,,,This field is of type List-User and has a limit of 20 entries. If the resulting value of this field in a record exceeds the maximum entries allowed the resulting value will be set to blank.,The ToUserList() formula can take Either all User fields or all UserList fields as arguments; not both. To combine a User field and a UserList field then you need convert the User field to a UserList field. +,,,,,"ToUserList([UserList field], ToUserList([User field]))" +,,,,, +,,,,,Associate Error: +,,,,,Expecting user but found userlist +Special,ToUserList,(UserList ul ..),UserList,Concatenates the contents of one or more user type fields into a user list field type.,"ToUserList([Sales Managers], [Business Managers]) takes the values in the Sales Manager field and Business Manager field and returns the combined value into another field." +,,,,, +,,,,This field is of type List-User and has a limit of 20 entries. If the resulting value of this field in a record exceeds the maximum entries allowed the resulting value will be set to blank.,The ToUserList() formula can take Either all User fields or all UserList fields as arguments; not both. To combine a User field and a UserList field then you need convert the User field to a UserList field. +,,,,,"ToUserList([UserList field], ToUserList([User field]))" +,,,,, +,,,,,Associate Error: +,,,,,Expecting user but found userlist +Dates,ToWeekdayN,(Date d),Date,"If the given date d is a weekday returns it, otherwise returns the next occurring weekday.","To WeekdayN([Order Date]) returns the date in the Order Date field if it's a weekday. If not, it returns the date of the next weekday." +,,,,, +,,,,,"ToWeekdayN(ToDate(""6/21/2003"")) returns 6/23/2003" +Dates,ToWeekdayP,(Date d),Date,"If the given date d is a weekday returns it, otherwise returns the previously occurring weekday.","ToWeekdayP(ToDate(""6/21/2003"")) returns 6/20/2003" +Durations,ToWeeks,(Duration d),Number,"Takes a Duration d, and returns the number of weeks contained in it.",ToWeeks(Days(14)) returns 2 +Type Conversion,ToWorkDate,(Date d),WorkDate,Converts a date d to a WorkDate.,ToWorkDate([Finish]) takes a date value from the Finish field and returns it as a Workdate. +,,,,, +,,,,,"ToWorkDate(ToDate(""10/31/2003"")) returns a work date whose value is 10/31/2003" +Text,Trim,(Text t),Text,"Returns t with leading and trailing white space characters removed. White space characters are space, tab, CR and LF.","Trim(""  ABC "") returns ""ABC""" +Text,Upper,(Text t),Text,Returns t converted to upper case.,"Upper(""abc"") returns ""ABC""" +Text,URLEncode,(Text t),Text,"Encodes the text t so that it can be used in a URL, by substituting special character combinations for certain reserved characters, like '&' and '=' and space.","URLEncode(""abc def&ghi=4"") returns ""abc+def%26ghi%3D4""" +Special,URLRoot,(),Text,"Returns the first part of the URL used to access QuickBase, including the protocol and the site name.","URLRoot() returns ""https://www.quickbase.com/""" +Special,User,(),User,Returns the user currently accessing the database.,With this function you can create a view that selects only the records modified by the user who is currently viewing the database. +Special,UserListToEmails,(UserList ul),text,Returns a semi-colon separated list of email addresses for all the users selected on the User List field. This function won't work for users who have hidden their e-mail address by choosing a screen name.,UserListToEmails([Assigned To]) returns the semi-colon separated list of email addresses of the QuickBase users who are part of Assigned To field for a record. +Special,UserListToIDs,(UserList ul),Text,Returns a semi-colon separated list of users' hashed IDs.,"UserListToIDs([Volunteers]), returns ""1234.abcd; 1000.dac3"" where the Volunteers are the users Michael Smith and Nancy Jones." +Special,UserListToNames,"(UserList ul, Text format)",text,"Returns a semi-colon separated list of users' full names. Specify text format by including ""FF"" to return the full names with the first name first. Or, include ""LF"" to return the full names with the last name first.","UserListToNames([Volunteers], ""LF""), returns ""Smith, Michael; Jones, Nancy"" where the Volunteers are the users Michael Smith and Nancy Jones." +Special,UserListToNames,(UserList ul),text,"Returns a semi-colon separated list of the users' full names, first name first.",UserListToNames([Volunteers]) returns the full names of the users listed in the Volunteers field. +Special,UserRoles,"(""ID/Name/Empty"")",Multi-select Text,Returns information about the current user’s role. Use in a Formula Multi-select Text field.,"UserRoles(""ID"") returns the role ID. UserRoles(""Name"") returns the role name. UserRoles(""""), or empty, returns the role ID and name in the format ID/name. " +,,,,, +,,,,,"Usage examples, based on the user role, include showing or hiding form elements, validating data, sending a webhook or email notification, or dynamic instructional text." +,,,,, +,,,,, +Special,UserToEmail,(User x),Text,Returns the user's e-mail address. This function won't work for users who have hidden their e-mail address.,UserToEmail([Last Modified By]) returns the email address of the QuickBase user who last modified a record. +Special,UserToID,(User x),Text,Returns a given user's hashed ID.,"UserToID([Record Owner]), returns ""1234.abcd"" where the record owner is the user Michael Smith." +Special,UserToName,"(User x, Text format)",Text,"Returns a given user's full name. Specify text format by including ""FF"" to return the full name with the first name first. Or, include ""LF"" to return the full name with the last name first.","UserToName([Record Owner], ""LF""), returns ""Smith, Michael"" where the record owner is the user Michael Smith." +Special,UserToName,(User x),Text,"Returns the user's full name, first name first.",UserToName([Record Owner]) returns the full name of the user listed in the Record Owner field.  +,,,,, +,,,,Use this function to turn a user name into a full name. For example: , +,,,,, +,,,,"UserToName(ToUser(""bboop"")) might return ""Betty Boop"". ", +Dates,WeekdayAdd,"(Date d, Number n)",Date,Returns the date that is n weekdays past the given date d. n may be negative to move backward in time.,"WeekdayAdd([Start], [Duration]) returns the date that results if you add the value in the Duration field to the date in the Start field and count only weekdays. " +,,,,, +,,,,,"WeekdayAdd(ToDate(""6/20/2003""), 2) returns 6/24/2003" +,,,,, +,,,,,"WeekdayAdd(ToDate(""6/24/2003""), -2) returns 6/20/2003" +,,,,, +,,,,,"If you have a date field named ""Start Date"" and that field has a value of 6/23/2003, then WeekdayAdd([Start Date], -2) returns 6/20/2003" +WorkDates,WeekdayAdd,"(WorkDate d, Number n)",WorkDate,"Returns the date obtained by adding n weekdays to the date d. The calculation includes the date d as one of the n days. For example, adding 2 days to 10/31/2003 which is a Friday will give you 11/03/2003 since it counts 10/31/2003 as 1 day and 11/03/2003 (a Monday) as the second day.","WeekdayAdd([Start],5) returns the date 5 weekdays after the date in the Start field." +,,,,, +,,,,,"WeekdayAdd(ToWorkDate(ToDate(""10/31/2003"")), 2) returns 11/03/2003" +,,,,, +,,,,,"WeekdayAdd([Field A], [Field B]) where Field A is a field of type WorkDate and Field B is a field of type Numeric. Assuming that the value of Field A is 10/31/2003 and the value of Field B is 2, the formula will return 11/03/2003" +Dates,WeekdaySub,"(Date d2, Date d1)",Number,"Returns the number of weekdays in the interval starting with d1 and ending on the day before d2 (same as subtracting Dates, but the result is the number of weekdays instead of a Duration). It is the inverse of WeekdayAdd.","WeekdaySub([Finish], [Start]) returns the number of weekdays between the dates in the Start and Finish fields." +,,,,, +,,,,,"WeekdaySub(ToDate(""6/24/2003""), ToDate(""6/20/2003"") ) returns 2" +,,,,, +Dates,WeekOfYear,(Date d),Number,Returns the number of weeks by which the given date d follows the first week of the year based on ISO standards. First day of week is a Monday. First week contains the first Thursday of January.,"WeekOfYear(ToDate(""Aug 20, 2000"")) returns 33" +,,,,,"WeekOfYear(ToDate(""Aug 20, 2000""), 1) returns 35" +Durations,Weeks,(Number n),Duration,"Returns a Duration representing n weeks. This function takes a number and converts it into a Duration type value, expressed in weeks.",Weeks(2) returns a 2 week duration +,,,,,"Weeks([Weeks until Delivery]) converts the numeric value in the Weeks until Delivery field into a duration expressed in weeks. The number doesn't change, just the data type." +WorkDates,WorkdayAdd,"(WorkDate d, Numeric n)",WorkDate,"Returns the date obtained by adding n days to the date d. The calculation includes the date d as one of the n days. For example, adding 2 days to 10/31/2003 which is a Friday will give you 11/01/2003 since it counts 10/31/2003 as 1 day and 11/01/2003 (a Saturday) as the second day.","WorkdayAdd([Order Date],7) returns the date seven days after the value in the Order Date field." +,,,,, +,,,,,"WorkdayAdd(ToWorkDate(ToDate(""10/31/2003"")), 1) returns 10/31/2003 while WorkdayAdd(ToWorkDate(ToDate(""10/31/2003"")), 2) returns 11/01/2003" +Dates,Year,(Date d),Number,Returns the year number of the Date d.,Year([Start Date]) returns the year of the date that appears in the Start Date field. +,,,,,"Year(ToDate(""Jan 10, 2000"")) returns 2000" diff --git a/reference/QuickBase Formula Examples.csv b/reference/QuickBase Formula Examples.csv new file mode 100644 index 0000000..e51123a --- /dev/null +++ b/reference/QuickBase Formula Examples.csv @@ -0,0 +1,135 @@ +Problem,Description,Category,Solution ,Solution In English,Formula Field Type,Discussion,Alternate Solution +Validate data based on a user’s role,Enforce business policies for what kind of data is allowed.,Text,"var Text roles = ToText(UserRoles(""ID""));","Let’s say you’re tracking sales opportunities, and you want to enforce business policies for what kind of discounts are allowed. Sales reps can discount up to 10%, sales managers can discount up to 15%, and any discount greater than 15% requires approval from the finance team. You can build a custom data rule using a formula - multi–select text field to enforce this policy. Consider an app with the following roles: Administrator, Finance Team, Sales Manager, and Sales Rep.",Multi-select text,, +,,,,,,, +,,,If( ,"Administrators and members of the finance team should be able to set any level of discount, but the discount levels sales reps and sales managers can apply should be limited.",,, +,,,"// Role with ID# 13 is ""Sales Rep"" ",,,, +,,,"(Left($roles,3) = ""13 "" or","Custom data rules allow you to harness the power of the Quick Base formula language to prevent the user from entering invalid data, displaying custom error messages you write in terms your end users will easily understand. You can enter complex, sophisticated business logic to determine exactly who should be allowed to enter certain types of data. ",,, +,,,"Contains($roles,""; 13 ;"") or",,,, +,,,"Right($roles,3) = "" 13"" or ","“ToText(UserRoles(""ID""))” is needed in this formula in six different places. Therefore, we’ll use a formula variable to improve legibility and speed up performance.",,, +,,,"(Contains($roles,""13"") and Length($roles) = 2)) and",,,, +,,,"[Discount %] > 0.1, ","You can find a role’s ID by going to Users - Manage Roles, then clicking on a role. On that page, look at the end of the URL to get the role ID.",,, +,,,"""Sales reps may set discounts up to 10%. Please adjust discount or speak with your manager to discuss a higher discount for this client."", ",,,, +,,,,"While you’ll need to replace the number in Contains(UserRoles(""ID""),""13"") with the role ID from your app, do not replace “ID” with anything specific to your app. Use the exact string “ID” in your custom data rule. The UserRoles() function has three different modes, and you use the text in quotation marks to choose which mode to use. You should use the ""ID"" mode for validating data for the same reason that Record ID# is a unique identifier for records. Each role has a unique role ID, but the names of roles may be similar (as in Sales Rep and Sales Manager above).",,, +,,,"// Role with ID# 10 is ""Sales Manager"" ",,,, +,,,"(Left($roles,3) = ""10 "" or","The multi–select text portion of the formula is UserRoles(""ID""), which returns a list of the numeric role IDs for all roles the current user has been granted in the app. In order to interpret the list of role IDs, we need to convert the list into a single piece of text. That gives us ToText(UserRoles(""ID"")). That expression is used in the formula above, which is entered in the table’s advanced settings as the custom data rule.",,, +,,,"Contains($roles,""; 10 ;"") or",,,, +,,,"Right($roles,3) = "" 10"" or ","In the example above, ToText() returns a semicolon-separated list. To search the list to match either the sales rep role, or the sales manager role, these notes apply:",,, +,,,"(Contains($roles,""10"") and Length($roles) = 2)) and",,,, +,,,"[Discount %] > 0.15, ","If the app includes many roles, there might be a role with ID #13 and another role with ID #130. To avoid such false positives, match ""; 13 ;"" instead of matching ""13"".",,, +,,,"""Sales managers may set discounts up to 15%. Please adjust discount or contact the finance team to discuss a higher discount for this client."" ","If the sales rep role is the first role in the list, there won’t be a semicolon before the number 13. Similarly, if the sales rep role is the last role in the list, there won’t be a semicolon after the number 13. Use the Left() and Right() functions to account for this.",,, +,,,),"If user is assigned only a single role in the app, then match on just 13 and then make sure there aren’t any more characters in the list with the Length() function.",,, +Calculate the date a week later,Create a formula date type field which displays the date that's a week later than the value in the Start Date field.,Dates,[Start Date] + Days (7),Take the value in the Start Date field and add seven days to it.,Date,, +"Automatically complete the Territory field, based on who the salesperson is.","Based on the value in the user field Salesperson, display the correponding sales territory in the Territory field.",Conditional (If - Then),"If([Salesperson]=ToUser(""baker@example.com""), ""Western"", ""Eastern"")","Take the e-mail address baker@example.com and convert it to the user value connected with that e-mail account (you can use a user name instead of an e-mail address). If the value in the Salesperson field is that user, then display the word Western, otherwise, display the word Eastern.",Text,Tip: Form rules can also automatically populate fields based on other values., +,,,,,,, +,,,,,,Want to set this up for multiple salespeople and territories? Use the Case() function instead. Read how in the next section., +Color rows based on who a user is,You want to color code rows based on the user in the Assigned To field.,Row Colorization,"If([Assigned To]=ToUser(""baker@example.com""), ""pink"", """")","Take the e-mail address baker@example.com and convert it to the user value connected with that e-mail account (you can use a user name instead of an e-mail address). If the value in the Assigned To field is that user, then color the row pink, otherwise, don't color it.",none - view builder formula,-Don't forget to enclose the e-mail address or user name in quotation marks., +I need a formula that returns the reporting period month. Any date after the 18th of the month will return a reporting period of the following month,,Dates,"If(Day([Date]) <= 18, Case((Month ([Date]))-1, 1,""January"",2,""February"",3,""March"",4,""April"",5,""May"",6,""June"",7,""July"",8,""August"",9,""September"",10,""October"",11,""November"",12,""December""), Case(Month ([Date]), 1,""January"",2,""February"",3,""March"",4,""April"",5,""May"",6,""June"",7,""July"",8,""August"",9,""September"",10,""October"",11,""November"",12,""December""))","If the day of the month entered into the [Date] Field is less than or equal to 18, then subtract one from the month of the date field and based on the month number return the matching name of month.",Date,, +,,,,,,, +,,,,"Else if the day of the month entered into the [date] field is greater than 18 then return the month of the [date field], and based on the month number return the matching month name.",,, +,,,,,,, +Calculate the number of weeks into the year a date is,In Excel I can use the WeekNum function to calculate how many weeks into the year a particular date is. How do I do this in QuickBase?,Dates,Int(DayofYear([datefield])/7+1) ,"Find the day of the year that the date field is (in other words how many days into the year the date is). Then divide by seven to get the number of weeks into the year. Add one. Then show the integer only (in other words, leave off any fractional values that follow the decimal point).",Numeric,"There is no week 0, so the first week is 1. That's why you add 1.","If you'd like to calculate the week number, but specify that each week begin on a Sunday (in other words, week one always ends on the first Saturday of January and following weeks are tallied based on that premise) try this formula instead:" +,,,,,,, +,,,,,,"This formulas returns the ""Absolute Week Number"" which counts weeks starting from January 1st, no matter what day of the week it is. In other words, Jan 1st through 7th is always week one. If Jan 1st is a Tuesday, than each week of the year will be Tuesday through Monday and tallied accordingly.",Int(DayOfYear(Days(DayOfWeek(FirstDayOfYear([MyDate]))) +,,,,,,,        + [MyDate])/7+1) +Set conditions across fields: If a user appears in any one of three fields show the record on a report,"Report needs to show records where the current user appears in any of three fields: Owner, Author or Reviewer",Conditional (If - Then),Create a custom formula column (type checkbox) with the following formula:,If the,Checkbox,"Because the custom column is a checkbox type field, you don't need to use an If() function to set the condition. If the conditions listed are met, the value in the custom column is automatically Yes (in other words, the checkbox is turned on).", +,,,,Owner field contains the value that is the current user or,,, +,,,[Owner]=User() or,Author field contains the value that is the current user or,,"Once you create this formula for the custom column of your report, you must set the filtering section to take advantage of it. Select Custom Column is and then type in the word YES.", +,,,[Author]=User() or,Reviewer field contains the value that is the current user,,, +,,,[Reviewer]=User(),then turn on the checkbox,,Read more about Custom Formula columns here: https://www.quickbase.com/help/custom_column_in_view.html, +Dynamically adjusting successor start dates from predecessor changes,Related to Project Management application,Calculation,"If( ([Status]<>""Completed""),WeekdayAdd([Start], [Duration]),","If the task is not completed, it calculates the expected end date (by adding the amount of time it will take to when it starts), and if the task is completed, then it uses the date that it was actually finished, as entered by the user completing the task.",Workdate,, +,,, ToWorkDate([Actual Finish Date])  ),,,, +,,,,,,, +,,,The predecessor field (under properties) needs to be changed from formula builder to Type-In.,,,, +Setting a sales commission based on sale price,Create a formula for the Sales Commission field that calculates commission based on sale price in the Total field,Conditional (If - Then),"If([Total]>=100 and [Total]<250, 25,","If total is greater than or equal to 100 and less than 250, then display the value 25.",Numeric,, +,,,"[Total]>=250 and [Total]<1000, 50,","If total is greater than or equal to 250 and less than 1000, then display the value 50",,, +,,,"[Total]>=1000, 100, 0)","If total is greater than or equal to 1000, then display 100",,, +,,,,,,, +,,,,Otherwise (if none of the above conditions are met) display 0.,,, +"Calculate a task's finish date, based on the start date and number entered in the Estimated # of Days field.",Adding a numeric value (Estimated # of Days) to a date value doesn't work.,Type Conversion,[Actual Start Date]+ Days([Estimated # of days]),Convert the number in the Estimated # of days field into a duration that represents a number of days and add it to the Actual Start Date.,Date,The Days() function converts the numeric value into a Duration value., +Need to see the day of the week for a date.,"If only the day of the week is needed and not the whole date, then this formula can be used.",Dates,"Case(DayOfWeek([YourDateFieldHere]), 0, ""Sunday"",","Note that the DayofWeek() takes a date, and not a workdate, so if the field is a workdate field, then you must convert it to date using ToDate().",Text,, +,,,"1, ""Monday"",",,,, +,,,"2, ""Tuesday"",",,,, +,,,"3, ""Wednesday"",",,,, +,,,"4, ""Thursday"",",,,, +,,,"5, ""Friday"",",,,, +,,,"6, ""Saturday"")",,,, +Find duration between two dates,Find the length of time between the date a record is created and the date the record's marked complete.,Dates,ToDays([Actual Finish Date] - ToDate([Date Created])),Take the value in the Date Created field (Date / Time type field) and convert it to a date value. Then subtract that date from the Actual Finish Date. Convert the resulting duration to a number of days.,Numeric,You must convert the this formula result to days to make it data type numeric.,If both dates live in Date type fields you can use a much simpler formula: [Actual Finish Date] - [Start Date] +,,,,,,, +,,,,,,If your result field type if formula-duration instead try:, +,,,,,,[Actual Finish Date] - ToDate([Date Created]), +Find duration between a date and today,"Find the length of time between a date and the current day's date (today). This is handy for finding the number of days until a planned finish date, for example.",Dates,ToDays([Planned Finish Date] - Today()),Subtract today's date from the Planned Finish Date.,Numeric,"If you want to display the result in a Duration type field instead, then you don't need the ToDays() function. Use this formula instead: ","If your result field is a Duration type field, try this formula instead:  [Planned Finish Date] - Today()" +,,,,,,[Planned Finish Date] - Today(), +Limit the text that an append field displays,"If you include an append field in a view, it often takes up a lot of room because it contains so much text. If you want, you can display only the last entry in the append field.",Text,"Right([History],""["")","Within the History field, take and display all characters to the right of the rightmost [ character.",Text,"Use this solution if your append field is set to APPEND entries. If the field is set to prepend entries instead, try: Part([History],2,""["")","If your append field is set to PREPEND text, use this formula instead: " +,,,,,,,"Part([History],2,""["")" +Convert dates to European format,Is there any way to change the default date format in my Date Identified field to the european DD/MM/YYYY?,Dates,"Left(NotLeft(ToText([Date Identified]),""-""),""-"")& ""-"" & ",,Text,You can't do this within a date field. You must create a formula - text type field to display dates in this format., +,,,"Case(Left(ToText([Date Identified]),2),",,,, +,,,"""01"",""Jan"",""02"",""Feb"",""03"",""Mar"",""04"",""Apr"",""05"",",,,, +,,,"""May"",""06"",""Jun"",""07"",""Jul"",""08"",""Aug"",""09"",""Sep"",",,,, +,,,"""10"",""Oct"",""11"",""Nov"",""12"",""Dec"")& ""-"" ",,,, +,,,"& Right(ToText([Date Identified]),""-"")",,,, +Calculate a date 1 year ago,Return a date that's exactly one year before the value in an existing date field (named Date).,Dates,"AdjustYear([Date],-1)",Take the value in the Date field and subract one from the year.,Date,Note that you can use the AdjustYear() function to go forward in time too., +Extract day and month from a date,You want to display only the day and month without the year.,Dates,"Left(ToText([Date]),5)",Convert the value in the Date field to a text type value. Then extract the leftmost 5 characters.,Text,"If the value in the Date field is 07-08-1967, this formula returns 07-08. Note that this formula produces a text type value.", +Calculate the date three business days later,Create a formula date type field which displays the date that's three business days after the value in the Start Date field.,Dates,"WeekdayAdd([Start Date],3)",Take the value in the Start Date field and add three weekdays to it.,Date,"This formula works when the value in the Start Date field is a date type value. If the Start Date field is a Workdate type field, do one of the following: ", +,,,,,,, +,,,,,,"- use the ToDate() conversion function: ToDate(WeekdayAdd([Start],3))", +,,,,,,, +,,,,,,or, +,,,,,,, +,,,,,,- Create a Formula Workdate type field to store results instead of a Formula Date type field., +"Remove ""the"" from names to enable alphabetical sorting","You have a list of organizations, some of which start with THE. You want to remove the THE so you can sort the list alphabetically.",Text,"If(Contains(Left([Organization Name],3),""The""), NotLeft([Organization Name],4), [Organization Name])","If the Organization Name field's leftmost three characters are ""the"" then remove the four leftmost characters (Why specify 4 instead of 3? To remove the space after ""the""). Otherwise (if there's no ""the"") just show the organization name.",Text,, +Highlight overdue tasks in pink,You want to color code rows based on the Due Date field.,Row Colorization,"if ([Due Date] < Today(), ""pink"", """")","If the value in the Due Date field is less than (e.g. earlier than) today, then color the row pink, otherwise, don't color it.",none - view builder formula,, +"Color each row differently, based on the status of each task.",You want to color code rows based on the value in the Status field.,Row Colorization,"case([Status], ""Not Started"", ""#FFCC99"", ","If the value in the Status field is ""Not Started,"" then color the row a shade of orange (#FFCC99). ",none - view builder formula,"This formula doesn't use the if() function. Because you want to set multiple conditions on one field, it's more efficient to use the case() function.", +,,,"""Started"", ""#99FFFF"", ","If the value in the Status field is ""Started,"" then color the row a shade of blue (#99FFFF).",,, +,,,"""In Progress"", ""#FFFFCC"", ","If the value in the Status field is ""In Progress,"" then color the row a shade of yellow (#FFFFCC).",,, +,,,"""On Hold"", ""#9999CC"", ","If the value in the Status field is ""On Hold,"" then color the row a shade of purple (#9999CC).",,, +,,,"""Completed"", ""#99FF99"", """")","If the value in the Status field is ""Completed,"" then color the row a shade of green (#99FF99)",,, +,,,,,,, +,,,,"If the value in the Status field is none of these, don't color it.",,, +Calculate the number of months until a payoff date,,Dates,((Year([Payoff Date]) - Year(Today()) )* 12) + (Month([Payoff Date]) - Month(Today()) ),Take this year (that today's date occurs in) and subtract it from the year listed in the Payoff Date field. Then multiply that number of years by 12 (to get months). Take that number of months and add it to the number of months that results from subtracting this month from the month listed in the Payoff date field.,Numeric,"You're really calculating the number of months in two steps. On the left side of the plus sign, you're figuring out how many years until the payoff date and converting that into months. On the right side of the plus sign, you're figuring out the difference in months. The plus sign then adds those figures together.", +Calculate an expiration date,Calculate an expiration date based on a contract start date and a period of months.,Dates,"AdjustMonth([Start Contract Date], [Contract Length in Months])",Take the date in the Start Contract Date Field and add the number of months in the Contract Length field.,Date,Contract Length is a numeric field which lists a number of months, +string together text snippets,"Concatenate (string together) text in two different fields. For example, create a field that lists a contacts full name by combing values from the First Name and Last Name fields",Text,[First Name] & “ “ & [Last Name],Display the value in the First Name field. Display a space. Display the value in the Last Name field.,Text,"Note: To create a space between the names, this formula inserts a text literal. QuickBase displays whatever characters appear between a set of double quotes--in this case, a space.", +Calculate the date one week from today,,Dates,Today() + Days(7),Display the date that is today plus 7 days.,Date,, +Add up the number of hours worked in a week.,Sum the values of hours entered for each day of the week.,Calculation,Nz([Mon]) + Nz([Tues] + Nz([Wed]) + Nz([Thurs]) + Nz([Fri]),"Return the value in the Mon field. If the Mon field is empty (null) then return zero. Add that to the value in the Tues field. If the Tues field is empty (null) then return zero. Add that to the value in the Wed field, and so on.",Numeric,"You'd use Nz here instead of IsNull, because in order to add these values together, QuickBase needs the result to be a number. Nz generates a zero for a null, which the program can use in the calculation.", +Display a value that depends upon the value in a percent complete field,"Show status ""not started"" ""in process"" or ""completed"" based on the value in the percent complete field.",Conditional (If - Then),"Case(true,","If the value in the percent complete field is zero, the display the text ""not started."" If the value in the percent complete field is less than 100%, then display the text ""in process."" If the value in the percent complete field is 100%, display the text ""complete""",Text,"A numeric percentage field shows values in percent format, like 50%, 75% and so on. But their real value is the exact mathematical representation of percent, which is always a portion of the whole number, one. For example, 20% is really.2 and 3% is really the number .03.", +,,,"[percent complete]=0,""not started"",",,,, +,,,"[percent complete]<1,""in process"",",,,, +,,,"[percent complete]=1,""complete"","""")",,,, +"Create a view that contains records for the ""current month"" only",,Conditional (If - Then),"If(Month([Date])=Month(Today()), ""Current Month"",""Not Current Month"")","If the month in the Date field is the same month as today, then display the text ""Current Month"" otherwise display the text ""Not Current Month.",Text,"Use this formula to create a view. You can feature the text field in the view to see which records say ""Current Month."" You can also use this custom formula field in the view's criteria. In other words, design the view so that you only see those records that occur in the Current Month. Read more about using custom columns in views here: https://www.quickbase.com/help/custom_column_in_view.html", +,,,,,,, +,,,,,,See also: https://www.quickbase.com/db/6mztyxu8?a=dr&r=n8&rl=xgi, +Show records for which events have occurred in the last week,Show records where any one of several date fields contains a date that occurred in the last week,Conditional (If - Then),"If(ToDays(Today()-[Sales Presentation])<7,true,false) or ","If you subtract the date in the Sales Presentation field from today and it's greater than seven, turn the checkbox field on (true), otherwise turn it off (false). ",Checkbox,"Use this formula to create a view. You can feature the checkbox field in the view to see which records have it checked and which don't. You can also use this custom formula field in the view's criteria. In other words, design the view so that you only see those records where this checkbox field is on. Read more about using custom columns in views here: https://www.quickbase.com/help/custom_column_in_view.html", +,,,"If(ToDays(Today()-[Follow-Up Meeting (2)])<7,true,false) or",,,, +,,,"If(ToDays(Today()-[Follow-Up Meeting (3)])<7,true,false) or","Each line of the formula beginning with ""If"" repeats these same conditions for each field listed. Each If/Then condition is joined by an ""or"" operator. This means that if the condition is met in any field, QuickBase will turn on the checkbox field.",,, +,,,"If(ToDays(Today()-[Follow-Up Meeting (4)])<7,true,false) or",,,, +,,,"If(ToDays(Today()-[STR Meeting])<7,true,false)",,,, +Find records that occur before the current month,,Dates,[Start Date]< FirstDayOfMonth(Today()),Start date is before the first day of the month in which today occurs,Checkbox,, +create a conditional salutation that can contain one or two names depending on content,"If the First Name 2 field contains a name, then string it together with the value in the First Name 1 field to create a salutation. If not, then just show the name in First Name 1. For example ""Mary & Joe"" -Or if there's no text in First name 2, just ""Mary""",Text,"If([First Name2]="""",[First Name 1], [First Name 1] & "" & "" & [First Name 2])","If First Name 2 is empty, then display the value in the First Name 1 field. Otherwise, display the value in the first name 1 field then display a space, an ampersand and a space followed by the value in the First Name 2 field.",Text,, +Extract and display only the most recent entry from an append field,Append fields often take up too much room in views. Use a formula field to show only the most recent entry.,Text,"Right([comments],""["")",Return the rightmost text from the comments field that follows the last occurence of the [ character.,Text,"This solution is for an append field that appends new entries. If the field were set to prepend new entries, the solution would be: Part([comments],2,""["")", +"If a field is not blank (null), then display a value in another field","Calculate the Revenue field, only if your staff has entered a date in the Submitted for Billing field.",Conditional (If - Then),Design the actual Revenue field using the following formula:,"If the Submitted for Billing field is not empty (or null), then display the value from the Revenue Forecast field.",Text,, +,,,,,,, +,,,"If(not IsNull([Submitted for Billing]),[Revenue Forecast])",,,, +Set status based on whether or not another field is checked.,"Automatically set the Status field to ""Complete,"" when a staff member enters a date in the Completion Date field.",Conditional (If - Then),Design the Status field using the following formula:,"If no one's entered a value in the Completion Date field (in other words that field is null) then display the word ""Pending."" If not, display the word ""Complete.""",Text,, +,,,,,,, +,,,"if(isnull([Completion Date]), ""Pending"", ""Complete"")",,,, +Calculate the number of checkboxes that a user turned on,Total the boxes checked (turned on) within a given record,Calculation,ToNumber([Checkbox1]) + ToNumber([Checkbox2]),Convert the value in Checkbox1 to a number and add it to the value in Checkbox 2. Include as many checkbox fields as necessary,Numeric,The ToNumber formula returns a 1 for true or yes and a 0 otherwise., +Convert a value representing time in minutes into seconds,When I enter a time in minutes and seconds (ie. 3.42) I need to be able to convert these to seconds.,Time,"ToNumber(Left([Minutes], ""."")) * 60 + ToNumber(Right([Minutes], "".""))","The Left function will return all the text up to but not including the period. In the example, that is 3. Since it is a text value, convert it into a number using the ToNumber function and then multiply the result by 60 to get seconds.",Numeric,Use a text field to store the number (like 3.42) since three minutes and 42 seconds is not the same thing as 3.42 seconds., +,,,,,,, +,,,,"Do the same to extract the text after the period using the Right function. Finally, we add it up to get the total seconds -- in this case it should be 3*60 + 50 = 230 seconds.",,, +Calculate how many fields are empty,"I need to create a field that counts the number of field that are non blank. The field that I need to count are labeled Model number 1, Model number 2 etc...",Calculation,ToNumber(IsNull([Model number 1])) + ,"IsNull returns a yes or no result, which when you convert it to a number using the ToNumber() function, becomes a one for yes or a zero for no. Insert a + operator to add all values together. Add as many additional fields as you need.",Numeric,"This solution assumes that the Model Number fields are numeric. If those  fields are text type fields, the IsNull() function wont work. Try something like the following instead:", +,,,ToNumber(IsNull([Model number 2])),,,, +,,,,,,"ToNumber(If (Length([Model number 1]) > 0, true, false) + ", +,,,,,,"ToNumber(If (Length([Model number 2]) > 0, true, false)", +Calculate the number of months that the date of last payment is past due,"I want to show the number of months overdue, but include complete months only, leaving off possible partial months.",Dates,Floor ( ToDays (Today() - [Date of Last Payment]) / 30),Subtract the Date of Last Payment from Today. ToDays converts that duration to a numeric value of days.  Divide that number by 30 and return the the number of complete months that have passed.,Numeric,"Floor() function returns the largest integer that's less than the number in question. For example, Floor(2.4) would return 2. Likewise, Floor (2.7) would return 2.", +Calculate project duration if project is completed,"If [Status] is ""Completed"", figure the duration based on [End Date], but if the status is anything else, figure duration on today's date.",Conditional (If - Then),"IF(([Status]=""Completed""),[End Date]-[Start Date],Today()- [Start Date])","If the value in the Status field is ""Completed"" then show the result of subtracting the Start Date from the End Date. Otherwise, show the result of subtracting the Start Date from today.",Duration,, +Tally duration fields even if one or two are empty (null),Total Actual Duration - [Actual Duration] + [Actual Duration 1] + [Actual Duration 2],Calculation,Nz([Actual Duration]) + Nz([Actual Duration 1]) +  Nz([Actual Duration 2]),"Return the value in the Actual Duration field. If the Actual Duration field is empty (null) then return zero. Add that to the value in the Actual Duration 1 field. If the Actual Duration 1 field is empty (null) then return zero. Add that to the value in the Actual Duration 2 field, and so on.",Duration,"You'd use the Nz() function here instead of IsNull(), because in order to add these values together, QuickBase needs the result to be a number. Nz generates a zero for a null, which the program can use in the calculation.", +View records from the previous week only,How do I create a view that on any day of the week I can view records dated from the previous week?,Dates,If([Date]>FirstDayOfWeek(Today()-Days(7)) and,"Subtract 7 days from today. Take the resulting date (let's call it ""one week ago date"") and calculate the first day of its week. If that is greater than the value in the Date field and If that date is less than the last day of the week in which ""one week ago date"" falls, the result is true (checkbox is on) otherwise the result is false (checkbox is off).",Checkbox,"You  can create a formula - checkbox type field to hold this formula  or just use the formula within the report (view) builder page. In this case, also select checkbox as the type, then within the Matching Criteria section, specify that is yes (type in the word yes).", +,,,"[Date]< LastDayOfWeek(Today()-Days(7)), ",,,, +,,,"true, ",,,, +,,,false),,,, +Calculate the date of the last Friday before a Due Date,"In each record, I want to calculate what date is the Friday that comes before each Due Date",Dates,"PrevDayOfWeek([Date Due], 5)",Take the value in the date due field and figure out the date of the previous week's Friday.,Date,5 represents Friday within the PrevDayOfWeek() function., +Show only records where any checkbox is turned on,Formula to create a view that shows records where [checkbox 1] is turned on OR [checkbox 2] is on OR [checkbox 3] is on and so on,Conditional (If - Then),If(([F1] or ,"If F1 or F2 or F3 checkboxes are turned on (True), then this formula returns True (yes), otherwise it returns false (no).",Checkbox,"You'd use this formula to create a ""custom column"" in the view builder. The custom column type is ""checkbox."" Then set the view's matching criteria to = Yes. When you do so, your view shows only those records that meet the condition of the formula. For more on using formulas to create views and reports, please read: https://www.quickbase.com/help/custom_column_in_view.html", +,,,[F2] or,,,, +,,,"[F3])=true, true, ",,,, +,,,false),,,, +Calculate how many gallons are used per hour,I want to divide gallons consumed (numeric field) by hours of operation (duration field).,Type Conversion,[gallons consumed]/ToHours([hours of operation]),Convert the duration in the hours of operation field to hours. Then divide the value in the gallons consumed field by that number of hours,Numeric,You can't divide a number by a duration. So you must convert the duration into a number using the ToHours() function., +Find the duration between two date fields of different types,Actual Finish is a date field and Actual Start is a workdate field. How can I find the duration between them without getting a type mismatch error?,Type Conversion,[Actual Finish]-ToDate([Actual Start]),Convert the value in the Actual Start field to a date type value. Then subtract that date from the Actual Finish date,Duration,, diff --git a/reference/example_image.png b/reference/example_image.png new file mode 100644 index 0000000..e0fbf9d Binary files /dev/null and b/reference/example_image.png differ diff --git a/reference/example_image_source.quickbase b/reference/example_image_source.quickbase new file mode 100644 index 0000000..f652b6b --- /dev/null +++ b/reference/example_image_source.quickbase @@ -0,0 +1,14 @@ +var text address = "123 Main Street" +var number areaCode = 216 + +var text dbUrl = URLRoot() & "db/" & [_DBID_MY_TABLE] +var text query = "?addr=" & $address + +var invalid = If(ToDate([End Date]) <= ([Start Date] + Days(30)), 1, 0) + +If(invalid and [Override], "Ok!", "Problem :(") + + + + + diff --git a/snippets/snippets.json b/snippets/snippets.json index 86efa90..51b5812 100644 --- a/snippets/snippets.json +++ b/snippets/snippets.json @@ -118,11 +118,13 @@ }, "Bool variable": { "prefix": [ + "var bool", "var Bool", + "bool", "Bool" ], "body": [ - "var Bool $1 = $2;" + "var bool $1 = $2;" ], "description": "Create a Bool variable" }, @@ -209,21 +211,25 @@ }, "Date variable": { "prefix": [ + "var date", "var Date", + "date", "Date" ], "body": [ - "var Date $1 = $2;" + "var date $1 = $2;" ], "description": "Create a Date variable" }, "Datetime variable": { "prefix": [ + "var datetime", "var Datetime", + "datetime", "Datetime" ], "body": [ - "var Datetime $1 = $2;" + "var datetime $1 = $2;" ], "description": "Create a Datetime variable" }, @@ -274,11 +280,13 @@ }, "Duration variable": { "prefix": [ + "var duration", "var Duration", + "duration", "Duration" ], "body": [ - "var Duration $1 = $2;" + "var duration $1 = $2;" ], "description": "Create a Duration variable" }, @@ -752,11 +760,13 @@ }, "Number variable": { "prefix": [ + "var number", "var Number", + "number", "Number" ], "body": [ - "var Number $1 = $2;" + "var number $1 = $2;" ], "description": "Create a Number variable" }, @@ -1032,21 +1042,27 @@ }, "Text variable": { "prefix": [ + "var text", "var Text", + "text", "Text" ], "body": [ - "var Text $1 = $2;" + "var text $1 = $2;" ], "description": "Create a Text variable" }, "Timeofday variable": { "prefix": [ + "var timeofday", + "var TimeOfDay", "var Timeofday", + "timeofday", + "TimeOfDay", "Timeofday" ], "body": [ - "var Timeofday $1 = $2;" + "var timeofday $1 = $2;" ], "description": "Create a Timeofday variable" }, @@ -1349,11 +1365,13 @@ }, "User variable": { "prefix": [ + "var user", "var User", + "user", "User" ], "body": [ - "var User $1 = $2;" + "var user $1 = $2;" ], "description": "Create a User variable" }, @@ -1485,14 +1503,241 @@ }, "Workdate variable": { "prefix": [ + "var workdate", + "var WorkDate", "var Workdate", + "workdate", + "WorkDate", "Workdate" ], "body": [ - "var Workdate $1 = $2;" + "var workdate $1 = $2;" ], "description": "Create a Workdate variable" }, + "Avg (RecordList records, Number fid)": { + "prefix": [ + "Avg" + ], + "body": [ + "Avg($1, $2)" + ], + "description": "Returns the average of the numeric values in field ID fid across the records returned by a formula query." + }, + "API Query String": { + "prefix": [ + "QBQuery", + "API Query" + ], + "body": [ + "\"{'${1:6}'.${2|EX,CT,WC,HAS,XHAS,TV,XTV,XEX,SW,XSW,BF,OBF,AF,OAF,IR,XIR,LT,LTE,GT,GTE|}.'${3:value}'}${4:OR}{'${5:7}'.${6|EX,CT,WC,HAS,XHAS,TV,XTV,XEX,SW,XSW,BF,OBF,AF,OAF,IR,XIR,LT,LTE,GT,GTE|}.'${7:value}'}\"" + ], + "description": "Create a Quickbase API query string." + }, + "GetAccessKey ()": { + "prefix": [ + "GetAccessKey" + ], + "body": [ + "GetAccessKey()" + ], + "description": "Returns a secure access key for use in Quickbase secure-link formulas." + }, + "GetFieldValues (RecordList records, Number fid)": { + "prefix": [ + "GetFieldValues" + ], + "body": [ + "GetFieldValues($1, $2)" + ], + "description": "Returns a list containing the values from field ID fid across the records in a record list." + }, + "GetRecord (Number rid)": { + "prefix": [ + "GetRecord" + ], + "body": [ + "GetRecord($1)" + ], + "description": "Returns a record list containing the record with the specified record ID from the current table." + }, + "GetRecord (Number rid, Table alias)": { + "prefix": [ + "GetRecord" + ], + "body": [ + "GetRecord($1, $2)" + ], + "description": "Returns a record list containing the record with the specified record ID from another table." + }, + "GetRecordByUniqueField (Text value, Number unique fid)": { + "prefix": [ + "GetRecordByUniqueField" + ], + "body": [ + "GetRecordByUniqueField($1, $2)" + ], + "description": "Returns a record list containing the record whose unique field matches the supplied value." + }, + "GetRecordByUniqueField (Text value, Number unique fid, Table alias)": { + "prefix": [ + "GetRecordByUniqueField" + ], + "body": [ + "GetRecordByUniqueField($1, $2, $3)" + ], + "description": "Returns a record list containing the record whose unique field matches the supplied value on another table." + }, + "GetRecords (Text query)": { + "prefix": [ + "GetRecords" + ], + "body": [ + "GetRecords($1)" + ], + "description": "Returns a record list for the records in the current table that match a Quickbase query string." + }, + "GetRecords (Text query, Table alias)": { + "prefix": [ + "GetRecords" + ], + "body": [ + "GetRecords($1, $2)" + ], + "description": "Returns a record list for the records in another table that match a Quickbase query string." + }, + "HTMLToText (Text html)": { + "prefix": [ + "HTMLToText" + ], + "body": [ + "HTMLToText($1)" + ], + "description": "Converts HTML markup to plain text." + }, + "Join (TextList values, Text delimiter)": { + "prefix": [ + "Join" + ], + "body": [ + "Join($1, $2)" + ], + "description": "Combines a text list into a single text value using the provided delimiter." + }, + "Median (RecordList records, Number fid)": { + "prefix": [ + "Median" + ], + "body": [ + "Median($1, $2)" + ], + "description": "Returns the median numeric value in field ID fid across the records returned by a formula query." + }, + "RecordList variable": { + "prefix": [ + "var recordlist", + "var RecordList", + "recordlist", + "RecordList" + ], + "body": [ + "var recordlist $1 = $2;" + ], + "description": "Create a RecordList variable" + }, + "RegexExtract (Text input, Text pattern)": { + "prefix": [ + "RegexExtract" + ], + "body": [ + "RegexExtract($1, $2)" + ], + "description": "Returns the text that matches the supplied regular expression." + }, + "RegexMatch (Text input, Text pattern)": { + "prefix": [ + "RegexMatch" + ], + "body": [ + "RegexMatch($1, $2)" + ], + "description": "Returns true if the input text matches the supplied regular expression." + }, + "RegexReplace (Text input, Text pattern, Text replacement)": { + "prefix": [ + "RegexReplace" + ], + "body": [ + "RegexReplace($1, $2, $3)" + ], + "description": "Replaces text that matches the supplied regular expression." + }, + "SHA256 (Text input)": { + "prefix": [ + "SHA256" + ], + "body": [ + "SHA256($1)" + ], + "description": "Returns the SHA-256 hash of the supplied text." + }, + "Size (RecordList records)": { + "prefix": [ + "Size" + ], + "body": [ + "Size($1)" + ], + "description": "Returns the number of records in a record list." + }, + "Size (TextList values)": { + "prefix": [ + "Size" + ], + "body": [ + "Size($1)" + ], + "description": "Returns the number of values in a multi-select text list." + }, + "Size (UserList values)": { + "prefix": [ + "Size" + ], + "body": [ + "Size($1)" + ], + "description": "Returns the number of values in a user list." + }, + "SumValues (RecordList records, Number fid)": { + "prefix": [ + "SumValues" + ], + "body": [ + "SumValues($1, $2)" + ], + "description": "Returns the sum of the numeric values in field ID fid across the records in a record list." + }, + "TextList variable": { + "prefix": [ + "var textlist", + "var TextList", + "textlist", + "TextList" + ], + "body": [ + "var textlist $1 = $2;" + ], + "description": "Create a TextList variable" + }, + "ToUnixTime (Date/Time value)": { + "prefix": [ + "ToUnixTime" + ], + "body": [ + "ToUnixTime($1)" + ], + "description": "Converts a date/time value to Unix time." + }, "WorkdayAdd (WorkDate d, Numeric n)": { "prefix": [ "WorkdayAdd" diff --git a/syntaxes/quickbase.tmLanguage.json b/syntaxes/quickbase.tmLanguage.json index d86fc66..d33dd31 100644 --- a/syntaxes/quickbase.tmLanguage.json +++ b/syntaxes/quickbase.tmLanguage.json @@ -2,12 +2,21 @@ "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", "name": "Quickbase Formula", "patterns": [ + { + "include": "#comment" + }, { "include": "#keyword" }, { "include": "#constant" }, + { + "include": "#numeric" + }, + { + "include": "#apiQuery" + }, { "include": "#strings" }, @@ -22,71 +31,276 @@ }, { "include": "#storage" - }, - { - "include": "#comment" } ], "repository": { - "comment": { - "patterns":[{ - "name": "comment.line", - "match": "\\/{2}.*" - }] + "apiQuery": { + "patterns": [ + { + "name": "meta.query.quickbase", + "begin": "\\{(?=\\s*(?:'[^']+'|\\d+)\\s*\\.)", + "beginCaptures": { + "0": { + "name": "keyword.control.block.quickbase.query" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "keyword.control.block.quickbase.query" + } + }, + "patterns": [ + { + "name": "variable.other.quickbase.query", + "match": "(?<=\\{\\s*)(?:'[^']+'|\\d+)(?=\\s*\\.)" + }, + { + "name": "keyword.operator.comparison.quickbase.query", + "match": "(?<=\\.)\\b(?:CT|XCT|WC|HAS|XHAS|EX|TV|XTV|XEX|SW|XSW|BF|OBF|AF|OAF|IR|XIR|LT|LTE|GT|GTE)\\b(?=\\.)" + }, + { + "name": "variable.other.quickbase.query", + "match": "\\{\\{[^{}]+\\}\\}" + }, + { + "name": "constant.language.quickbase.query.special", + "match": "(?<=\\.[A-Z]+\\.)'(?i:today|_curuser_|-?\\d+\\s+days\\s+ago)'(?=\\s*\\})" + }, + { + "name": "constant.language.quickbase.query.special", + "match": "(?<=\\.[A-Z]+\\.)(?i:today|_curuser_)(?=\\s*\\})" + }, + { + "name": "constant.language.quickbase.query", + "match": "(?<=\\.[A-Z]+\\.)[A-Za-z_][A-Za-z0-9_]*(?=\\s*\\})" + }, + { + "name": "string.quoted.double.quickbase.query", + "begin": "\"", + "end": "\"", + "patterns": [ + { + "name": "constant.character.escape.quickbase", + "match": "\\\\." + } + ] + }, + { + "name": "string.quoted.single.quickbase.query", + "begin": "'", + "end": "'", + "patterns": [ + { + "name": "constant.character.escape.quickbase", + "match": "\\\\." + }, + { + "name": "variable.other.quickbase.query", + "match": "\\{\\{[^{}]+\\}\\}" + } + ] + }, + { + "name": "punctuation.separator.dot.quickbase.query", + "match": "\\." + } + ] + }, + { + "name": "keyword.operator.comparison.quickbase.query", + "match": "(?<=\\.)\\b(?:CT|XCT|WC|HAS|XHAS|EX|TV|XTV|XEX|SW|XSW|BF|OBF|AF|OAF|IR|XIR|LT|LTE|GT|GTE)\\b(?=\\.)" + }, + { + "name": "variable.other.quickbase.query", + "match": "\\{\\{[^{}]+\\}\\}" + }, + { + "name": "keyword.control.quickbase.query", + "match": "\\b(?i:and|or)\\b" + } + ] + }, + "apiQueryString": { + "patterns": [ + { + "name": "meta.query.quickbase", + "begin": "\\{(?=\\s*(?:'[^']+'|\\d+)\\s*\\.[^\"\\r\\n]*\\})", + "beginCaptures": { + "0": { + "name": "keyword.control.block.quickbase.query" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "keyword.control.block.quickbase.query" + } + }, + "patterns": [ + { + "name": "variable.other.quickbase.query", + "match": "(?<=\\{\\s*)(?:'[^']+'|\\d+)(?=\\s*\\.)" + }, + { + "name": "keyword.operator.comparison.quickbase.query", + "match": "(?<=\\.)\\b(?:CT|XCT|WC|HAS|XHAS|EX|TV|XTV|XEX|SW|XSW|BF|OBF|AF|OAF|IR|XIR|LT|LTE|GT|GTE)\\b(?=\\.)" + }, + { + "name": "variable.other.quickbase.query", + "match": "\\{\\{[^{}]+\\}\\}" + }, + { + "name": "constant.language.quickbase.query.special", + "match": "(?<=\\.[A-Z]+\\.)'(?i:today|_curuser_|-?\\d+\\s+days\\s+ago)'(?=\\s*\\})" + }, + { + "name": "constant.language.quickbase.query.special", + "match": "(?<=\\.[A-Z]+\\.)(?i:today|_curuser_)(?=\\s*\\})" + }, + { + "name": "constant.language.quickbase.query", + "match": "(?<=\\.[A-Z]+\\.)[A-Za-z_][A-Za-z0-9_]*(?=\\s*\\})" + }, + { + "name": "string.quoted.double.quickbase.query", + "begin": "\"", + "end": "\"", + "patterns": [ + { + "name": "constant.character.escape.quickbase", + "match": "\\\\." + } + ] + }, + { + "name": "string.quoted.single.quickbase.query", + "begin": "'", + "end": "'", + "patterns": [ + { + "name": "constant.character.escape.quickbase", + "match": "\\\\." + }, + { + "name": "variable.other.quickbase.query", + "match": "\\{\\{[^{}]+\\}\\}" + } + ] + }, + { + "name": "punctuation.separator.dot.quickbase.query", + "match": "\\." + } + ] + }, + { + "name": "keyword.operator.comparison.quickbase.query", + "match": "(?<=\\.)\\b(?:CT|XCT|WC|HAS|XHAS|EX|TV|XTV|XEX|SW|XSW|BF|OBF|AF|OAF|IR|XIR|LT|LTE|GT|GTE)\\b(?=\\.)" + }, + { + "name": "variable.other.quickbase.query", + "match": "\\{\\{[^{}]+\\}\\}" + }, + { + "name": "keyword.control.quickbase.query", + "match": "\\b(?i:and|or)\\b" + } + ] + }, "comment": { + "patterns": [ + { + "name": "comment.line.quickbase", + "match": "\\/{2}.*" + } + ] }, "constant": { - "patterns": [{ - "name": "constant.language.quickbase", - "match": "\\b(true|false|null)\\b" - }] + "patterns": [ + { + "name": "constant.language.quickbase", + "match": "\\b(?i:true|false|null)\\b" + } + ] }, "keyword": { - "patterns": [{ - "name": "keyword.control.quickbase", - "match": "\\b(and|or|not)\\b" - }] + "patterns": [ + { + "name": "keyword.control.quickbase", + "match": "\\b(?i:and|or|not)\\b" + } + ] + }, + "numeric": { + "patterns": [ + { + "name": "constant.numeric.quickbase", + "match": "\\b\\d+(?:\\.\\d+)?\\b" + } + ] }, "storage": { - "patterns": [{ - "name": "storage.type.quickbase", - "match": "\\bvar\\b" - }, - { - "name": "storage.modifier.quickbase", - "match": "\\b(Bool|Text|Number|Date|Datetime|Duration|Timeofday|Workdate|User)\\b" - }] + "patterns": [ + { + "name": "keyword.control.declaration.quickbase", + "match": "\\b(?i:var)\\b" + }, + { + "name": "entity.name.type.quickbase", + "match": "\\b(?i:bool|number|text|textlist|date|datetime|duration|timeofday|workdate|user|recordlist)\\b" + } + ] }, "operator": { - "patterns": [{ - "name": "keyword.operator.quickbase", - "match": "(\\+|-|\\*|(?=|<=|>|<)" - }] + "patterns": [ + { + "name": "keyword.operator.quickbase", + "match": "(<>|!=|>=|<=|(?|<)" + } + ] }, "variable": { - "patterns": [{ - "name": "variable.language.quickbase", - "match": "\\[[^\\[]+]" - }, - { - "name": "variable.other.quickbase", - "match": "\\w([\\w_-]+)(?=\\s*(=)\\s*)" - }, - { - "name": "variable.other.quickbase", - "match": "(\\$[\\w_-]+)" - }] + "patterns": [ + { + "name": "variable.other.quickbase", + "match": "\\[[^\\]\\r\\n]+\\]" + }, + { + "match": "\\b((?i:var))\\s+((?i:bool|number|text|textlist|date|datetime|duration|timeofday|workdate|user|recordlist))\\s+([A-Za-z][A-Za-z0-9_]*)\\b", + "captures": { + "1": { + "name": "keyword.control.declaration.quickbase" + }, + "2": { + "name": "entity.name.type.quickbase" + }, + "3": { + "name": "variable.other.quickbase" + } + } + }, + { + "name": "variable.other.quickbase", + "match": "\\$[A-Za-z][A-Za-z0-9_]*\\b" + } + ] }, "support": { - "patterns": [{ - "name": "support.function.quickbase", - "match": "\\b(Abs|AdjustMonth|AdjustYear|AppID|Average|Base64Decode|Base64Encode|Begins|Case|Ceil|Contains|Count|Date|Day|DayOfWeek|DayOfYear|Days|Dbid|Ends|Exp|FirstDayOfMonth|FirstDayOfPeriod|FirstDayOfWeek|FirstDayOfYear|Floor|Frac|GetFieldProperty|Hour|Hours|If|Includes|Int|IsLeapDay|IsLeapYear|IsNull|IsUserEmail|IsWeekday|LastDayOfMonth|LastDayOfPeriod|LastDayOfWeek|LastDayOfYear|Left|Length|List|Ln|Log|Lower|Max|Mid|Min|Minute|Minutes|Mod|Month|MSecond|MSeconds|NameOfMonth|NextDayOfWeek|NotLeft|NotRight|Now|Nz|PadLeft|PadRight|Part|PrevDayOfWeek|PV|QB32Decode|QB32Encode|Rem|Right|Round|SearchAndReplace|Second|Seconds|Split|Sqrt|Sum|ToBoolean|ToDate|Today|ToDays|ToFormattedText|ToHours|ToMinutes|ToMSeconds|ToNumber|ToSeconds|ToText|ToTimeOfDay|ToTimestamp|ToUser|ToUserList|ToWeekdayN|ToWeekdayP|ToWeeks|ToWorkDate|Trim|Upper|URLEncode|URLRoot|User|UserListToEmails|UserListToIds|UserListToNames|UserRoles|UserToEmail|UserToId|UserToName|WeekdayAdd|WeekdaySub|WeekOfYear|Weeks|WorkdayAdd|Year)\\b" - }] + "patterns": [ + { + "name": "support.function.quickbase", + "match": "\\b(?i:Abs|AdjustMonth|AdjustYear|AppID|Average|Avg|Base64Decode|Base64Encode|Begins|Case|Ceil|Contains|Count|Date|Day|DayOfWeek|DayOfYear|Days|Dbid|Ends|Exp|FirstDayOfMonth|FirstDayOfPeriod|FirstDayOfWeek|FirstDayOfYear|Floor|Frac|GetAccessKey|GetFieldProperty|GetFieldValues|GetRecord|GetRecordByUniqueField|GetRecords|HTMLToText|Hour|Hours|If|Includes|Int|IsLeapDay|IsLeapYear|IsNull|IsUserEmail|IsWeekday|Join|LastDayOfMonth|LastDayOfPeriod|LastDayOfWeek|LastDayOfYear|Left|Length|List|Ln|Log|Lower|Max|Median|Mid|Min|Minute|Minutes|Mod|Month|MSecond|MSeconds|NameOfMonth|NextDayOfWeek|NotLeft|NotRight|Now|Nz|PadLeft|PadRight|Part|PrevDayOfWeek|PV|QB32Decode|QB32Encode|RegexExtract|RegexMatch|RegexReplace|Rem|Right|Round|SearchAndReplace|Second|Seconds|SHA256|Size|Split|Sqrt|Sum|SumValues|ToBoolean|ToDate|Today|ToDays|ToFormattedText|ToHours|ToMinutes|ToMSeconds|ToNumber|ToSeconds|ToText|ToTimeOfDay|ToTimestamp|ToUnixTime|ToUser|ToUserList|ToWeekdayN|ToWeekdayP|ToWeeks|ToWorkDate|Trim|Upper|URLEncode|URLRoot|User|UserListToEmails|UserListToIds|UserListToNames|UserRoles|UserToEmail|UserToId|UserToName|WeekdayAdd|WeekdaySub|WeekOfYear|Weeks|WorkdayAdd|Year)\\b(?=\\s*\\()" + } + ] }, "strings": { "name": "string.quoted.double.quickbase", "begin": "\"", "end": "\"", "patterns": [ + { + "include": "#apiQueryString" + }, { "name": "constant.character.escape.quickbase", "match": "\\\\." @@ -95,4 +309,6 @@ } }, "scopeName": "source.quickbase" -} \ No newline at end of file +} + + diff --git a/tests/Invoke-QuickbaseTokenization.ps1 b/tests/Invoke-QuickbaseTokenization.ps1 new file mode 100644 index 0000000..92bf500 --- /dev/null +++ b/tests/Invoke-QuickbaseTokenization.ps1 @@ -0,0 +1,384 @@ +param( + [ValidateSet('verify', 'update')] + [string]$Mode = 'verify' +) + +Set-StrictMode -Version Latest + +$fixturesDirectory = Join-Path $PSScriptRoot 'fixtures' +$snapshotsDirectory = Join-Path $PSScriptRoot 'snapshots' +$grammarPath = Join-Path $PSScriptRoot '..\syntaxes\quickbase.tmLanguage.json' +$grammar = Get-Content $grammarPath -Raw | ConvertFrom-Json +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) + +function New-Range { + param( + [int]$Start, + [int]$End + ) + + [PSCustomObject]@{ + Start = $Start + End = $End + } +} + +function New-Token { + param( + [int]$Start, + [int]$End, + [string]$Text, + [string]$Scope + ) + + [PSCustomObject]@{ + Start = $Start + End = $End + Text = $Text + Scope = $Scope + } +} + +function ConvertTo-SnapshotToken { + param( + [Parameter(Mandatory = $true)] + $Token + ) + + [ordered]@{ + start = $Token.Start + end = $Token.End + text = $Token.Text + scope = $Token.Scope + } +} + +function Normalize-SnapshotText { + param( + [string]$Text + ) + + (($Text -replace "`r`n", "`n").TrimEnd("`r", "`n")) + "`n" +} + +function Test-InRanges { + param( + [int]$Start, + [int]$End, + [object[]]$Ranges + ) + + foreach ($range in $Ranges) { + if ($Start -ge $range.Start -and $End -le $range.End) { + return $true + } + } + + return $false +} + +function Get-RegexTokens { + param( + [string]$Text, + [string]$Pattern, + [string]$Scope, + [int]$Offset = 0 + ) + + $tokens = @() + foreach ($match in [regex]::Matches($Text, $Pattern)) { + if ($match.Length -eq 0) { + continue + } + + $tokens += New-Token -Start ($Offset + $match.Index) -End ($Offset + $match.Index + $match.Length) -Text $match.Value -Scope $Scope + } + + return $tokens +} + +function Get-DeclarationTokens { + param( + [string]$Text, + [string]$Pattern + ) + + $tokens = @() + foreach ($match in [regex]::Matches($Text, $Pattern)) { + if (-not $match.Success) { + continue + } + + foreach ($captureInfo in @( + @{ Group = 1; Scope = 'keyword.control.declaration.quickbase' }, + @{ Group = 2; Scope = 'entity.name.type.quickbase' }, + @{ Group = 3; Scope = 'variable.other.quickbase' } + )) { + $group = $match.Groups[$captureInfo.Group] + if ($group.Success -and $group.Length -gt 0) { + $tokens += New-Token -Start $group.Index -End ($group.Index + $group.Length) -Text $group.Value -Scope $captureInfo.Scope + } + } + } + + return $tokens +} + +function Get-TopLevelTokens { + param( + [string]$LineText, + $Grammar + ) + + $commentPattern = $Grammar.repository.comment.patterns[0].match + $stringPattern = '"(?:[^"\\]|\\.)*"' + $declarationPattern = $Grammar.repository.variable.patterns[1].match + + $commentTokens = Get-RegexTokens -Text $LineText -Pattern $commentPattern -Scope 'comment.line.quickbase' + $stringTokens = Get-RegexTokens -Text $LineText -Pattern $stringPattern -Scope 'string.quoted.double.quickbase' + + $commentRanges = @($commentTokens | ForEach-Object { New-Range -Start $_.Start -End $_.End }) + $stringRanges = @($stringTokens | ForEach-Object { New-Range -Start $_.Start -End $_.End }) + + $candidates = @() + $candidates += $commentTokens + $candidates += $stringTokens + $candidates += Get-DeclarationTokens -Text $LineText -Pattern $declarationPattern + $candidates += Get-RegexTokens -Text $LineText -Pattern $Grammar.repository.variable.patterns[0].match -Scope $Grammar.repository.variable.patterns[0].name + $candidates += Get-RegexTokens -Text $LineText -Pattern $Grammar.repository.variable.patterns[2].match -Scope 'variable.other.quickbase' + $candidates += Get-RegexTokens -Text $LineText -Pattern $Grammar.repository.keyword.patterns[0].match -Scope 'keyword.control.quickbase' + $candidates += Get-RegexTokens -Text $LineText -Pattern $Grammar.repository.constant.patterns[0].match -Scope 'constant.language.quickbase' + $candidates += Get-RegexTokens -Text $LineText -Pattern $Grammar.repository.numeric.patterns[0].match -Scope 'constant.numeric.quickbase' + $candidates += Get-RegexTokens -Text $LineText -Pattern $Grammar.repository.operator.patterns[0].match -Scope 'keyword.operator.quickbase' + $candidates += Get-RegexTokens -Text $LineText -Pattern $Grammar.repository.support.patterns[0].match -Scope 'support.function.quickbase' + $candidates += Get-RegexTokens -Text $LineText -Pattern $Grammar.repository.storage.patterns[0].match -Scope $Grammar.repository.storage.patterns[0].name + $candidates += Get-RegexTokens -Text $LineText -Pattern $Grammar.repository.storage.patterns[1].match -Scope $Grammar.repository.storage.patterns[1].name + + $filtered = foreach ($token in $candidates) { + if ($token.Scope -ne 'comment.line.quickbase' -and (Test-InRanges -Start $token.Start -End $token.End -Ranges $commentRanges)) { + continue + } + + if ($token.Scope -ne 'string.quoted.double.quickbase' -and (Test-InRanges -Start $token.Start -End $token.End -Ranges $stringRanges)) { + continue + } + + $token + } + + return @( + $filtered | + Sort-Object Start, End, Scope | + Select-Object -Unique Start, End, Text, Scope | + ForEach-Object { ConvertTo-SnapshotToken -Token $_ } + ) +} + +function Get-QueryBlocks { + param( + [string]$LineText + ) + + $blocks = @() + + for ($index = 0; $index -lt $LineText.Length; $index += 1) { + if ($LineText[$index] -ne '{') { + continue + } + + $remaining = $LineText.Substring($index) + if (-not [regex]::IsMatch($remaining, '^\{(?=\s*(?:''[^'']+''|\d+)\s*\.)')) { + continue + } + + $cursor = $index + 1 + while ($cursor -lt $LineText.Length) { + if ($LineText[$cursor] -eq '"') { + break + } + + if (($cursor + 1) -lt $LineText.Length -and $LineText.Substring($cursor, 2) -eq '{{') { + $placeholderEnd = $LineText.IndexOf('}}', $cursor + 2) + if ($placeholderEnd -lt 0) { + $cursor = $LineText.Length + break + } + + $cursor = $placeholderEnd + 2 + continue + } + + if ($LineText[$cursor] -eq '}') { + $blockEnd = $cursor + 1 + $blocks += [ordered]@{ + Start = $index + End = $blockEnd + Text = $LineText.Substring($index, $blockEnd - $index) + } + $index = $cursor + break + } + + $cursor += 1 + } + } + + return $blocks +} + +function Get-QuerySnapshots { + param( + [string]$LineText, + $Grammar + ) + + $fieldPattern = $Grammar.repository.apiQuery.patterns[0].patterns[0].match + $operatorPattern = $Grammar.repository.apiQuery.patterns[0].patterns[1].match + $placeholderPattern = $Grammar.repository.apiQuery.patterns[0].patterns[2].match + $specialQuotedValuePattern = $Grammar.repository.apiQuery.patterns[0].patterns[3].match + $specialBareValuePattern = $Grammar.repository.apiQuery.patterns[0].patterns[4].match + $bareValuePattern = $Grammar.repository.apiQuery.patterns[0].patterns[5].match + $doubleQuotedPattern = '"(?:[^"\\]|\\.)*"' + $singleQuotedPattern = '''(?:[^''\\]|\\.)*''' + $dotPattern = '\.' + + $snapshots = @() + + foreach ($queryBlock in (Get-QueryBlocks -LineText $LineText)) { + $queryText = $queryBlock.Text + $queryStart = $queryBlock.Start + $queryEnd = $queryBlock.End + + $fieldTokens = Get-RegexTokens -Text $queryText -Pattern $fieldPattern -Scope 'variable.other.quickbase.query' -Offset $queryStart + $specialQuotedTokens = Get-RegexTokens -Text $queryText -Pattern $specialQuotedValuePattern -Scope 'constant.language.quickbase.query.special' -Offset $queryStart + $specialBareTokens = Get-RegexTokens -Text $queryText -Pattern $specialBareValuePattern -Scope 'constant.language.quickbase.query.special' -Offset $queryStart + $fieldRanges = @($fieldTokens | ForEach-Object { New-Range -Start $_.Start -End $_.End }) + $specialRanges = @((@($specialQuotedTokens) + @($specialBareTokens)) | ForEach-Object { New-Range -Start $_.Start -End $_.End }) + + $tokens = @() + $tokens += New-Token -Start $queryStart -End ($queryStart + 1) -Text '{' -Scope 'keyword.control.block.quickbase.query' + $tokens += $fieldTokens + $tokens += Get-RegexTokens -Text $queryText -Pattern $operatorPattern -Scope 'keyword.operator.comparison.quickbase.query' -Offset $queryStart + $tokens += Get-RegexTokens -Text $queryText -Pattern $placeholderPattern -Scope 'variable.other.quickbase.query' -Offset $queryStart + $tokens += $specialQuotedTokens + $tokens += $specialBareTokens + + foreach ($bareToken in (Get-RegexTokens -Text $queryText -Pattern $bareValuePattern -Scope 'constant.language.quickbase.query' -Offset $queryStart)) { + if (Test-InRanges -Start $bareToken.Start -End $bareToken.End -Ranges $specialRanges) { + continue + } + + $tokens += $bareToken + } + + $tokens += Get-RegexTokens -Text $queryText -Pattern $doubleQuotedPattern -Scope 'string.quoted.double.quickbase.query' -Offset $queryStart + + foreach ($singleToken in (Get-RegexTokens -Text $queryText -Pattern $singleQuotedPattern -Scope 'string.quoted.single.quickbase.query' -Offset $queryStart)) { + if (Test-InRanges -Start $singleToken.Start -End $singleToken.End -Ranges $fieldRanges) { + continue + } + + if (Test-InRanges -Start $singleToken.Start -End $singleToken.End -Ranges $specialRanges) { + continue + } + + $tokens += $singleToken + } + + $tokens += Get-RegexTokens -Text $queryText -Pattern $dotPattern -Scope 'punctuation.separator.dot.quickbase.query' -Offset $queryStart + $tokens += New-Token -Start ($queryEnd - 1) -End $queryEnd -Text '}' -Scope 'keyword.control.block.quickbase.query' + + $orderedTokens = @( + $tokens | + Sort-Object Start, End, Scope | + Select-Object -Unique Start, End, Text, Scope | + ForEach-Object { ConvertTo-SnapshotToken -Token $_ } + ) + + $snapshots += [ordered]@{ + start = $queryStart + end = $queryEnd + text = $queryText + tokens = $orderedTokens + } + } + + return $snapshots +} + +function Get-LineSnapshot { + param( + [int]$LineNumber, + [string]$LineText, + $Grammar + ) + + [ordered]@{ + line = $LineNumber + text = $LineText + topLevel = @(Get-TopLevelTokens -LineText $LineText -Grammar $Grammar) + queries = @(Get-QuerySnapshots -LineText $LineText -Grammar $Grammar) + } +} + +function Get-FixtureSnapshot { + param( + [string]$FixturePath, + $Grammar + ) + + $content = Get-Content $FixturePath -Raw + $lines = $content -replace "`r`n", "`n" -split "`n" + if ($lines.Count -gt 0 -and $lines[-1] -eq '') { + $lines = $lines[0..($lines.Count - 2)] + } + + $snapshot = @() + for ($index = 0; $index -lt $lines.Count; $index += 1) { + $snapshot += Get-LineSnapshot -LineNumber ($index + 1) -LineText $lines[$index] -Grammar $Grammar + } + + return $snapshot +} + +if (-not (Test-Path $snapshotsDirectory)) { + New-Item -ItemType Directory -Force $snapshotsDirectory | Out-Null +} + +$fixturePaths = Get-ChildItem $fixturesDirectory -Filter *.quickbase | Where-Object { $_.BaseName -notlike 'diagnostics-*' } | Sort-Object Name | Select-Object -ExpandProperty FullName +if ($fixturePaths.Count -eq 0) { + throw 'No .quickbase fixtures were found for snapshot testing.' +} + +$hasMismatch = $false + +foreach ($fixturePath in $fixturePaths) { + $snapshot = Get-FixtureSnapshot -FixturePath $fixturePath -Grammar $grammar + $snapshotPath = Join-Path $snapshotsDirectory (([System.IO.Path]::GetFileNameWithoutExtension($fixturePath)) + '.snapshot.json') + $snapshotText = Normalize-SnapshotText -Text ($snapshot | ConvertTo-Json -Depth 10) + + if ($Mode -eq 'update') { + [System.IO.File]::WriteAllText($snapshotPath, $snapshotText, $utf8NoBom) + Write-Host "Updated $(Split-Path $snapshotPath -Leaf)" + continue + } + + if (-not (Test-Path $snapshotPath)) { + Write-Host "Missing snapshot $(Split-Path $snapshotPath -Leaf)" + $hasMismatch = $true + continue + } + + $expectedSnapshotText = Normalize-SnapshotText -Text (Get-Content $snapshotPath -Raw) + if ($expectedSnapshotText -ne $snapshotText) { + Write-Host "Snapshot mismatch $(Split-Path $snapshotPath -Leaf)" + $hasMismatch = $true + continue + } + + Write-Host "Verified $(Split-Path $snapshotPath -Leaf)" +} + +if ($hasMismatch) { + throw 'Grammar snapshots are out of date. Run the snapshot runner in update mode to refresh them.' +} \ No newline at end of file diff --git a/tests/QuickbaseDiagnostics.Tests.ps1 b/tests/QuickbaseDiagnostics.Tests.ps1 new file mode 100644 index 0000000..fbd1768 --- /dev/null +++ b/tests/QuickbaseDiagnostics.Tests.ps1 @@ -0,0 +1,55 @@ +Set-StrictMode -Version Latest + +function Invoke-QuickbaseDiagnostics { + param( + [Parameter(Mandatory = $true)] + [string]$FixtureName + ) + + @(cscript //nologo .\tests\RunQuickbaseDiagnostics.js (Join-Path .\tests\fixtures $FixtureName)) +} + +Describe 'Quickbase diagnostics' { + It 'accepts a valid Quickbase formula without diagnostics' { + $output = Invoke-QuickbaseDiagnostics -FixtureName 'diagnostics-valid.quickbase' + + ($output -join "`n") | Should Be 'OK' + } + + It 'accepts a standalone Quickbase query expression without diagnostics' { + $output = Invoke-QuickbaseDiagnostics -FixtureName 'diagnostics-standalone-query.quickbase' + + ($output -join "`n") | Should Be 'OK' + } + + It 'accepts newline-separated statements without an explicit semicolon' { + $output = Invoke-QuickbaseDiagnostics -FixtureName 'diagnostics-newline-separated.quickbase' + + ($output -join "`n") | Should Be 'OK' + } + + It 'flags semantic and type issues' { + $output = Invoke-QuickbaseDiagnostics -FixtureName 'diagnostics-semantic-errors.quickbase' + $joined = $output -join "`n" + + $joined | Should Match 'QB104\|warning\|1:30\|Join\(\) argument 1 expects TextList but received Text\.' + $joined | Should Match 'QB102\|warning\|2:25\|Declared type Number does not match initializer type Text\.' + $joined | Should Match 'QB104\|warning\|2:30\|Join\(\) argument 1 expects TextList but received Text\.' + $joined | Should Match 'QB107\|error\|3:24\|Invalid Quickbase query operator "BAD"\.' + $joined | Should Match 'QB100\|error\|4:12\|Duplicate variable declaration \$query\.' + $joined | Should Match 'QB101\|error\|5:22\|Unknown variable \$missingOwner\.' + $joined | Should Match 'QB109\|error\|6:10\|Quickbase variable names must use letters only\.' + $joined | Should Match 'QB110\|error\|6:23\|Quickbase query operators must be uppercase\.' + $joined | Should Match 'QB111\|warning\|8:43\|Regex pattern arguments must be string literals in Quickbase formulas\.' + } + + It 'flags structural validation issues' { + $output = Invoke-QuickbaseDiagnostics -FixtureName 'diagnostics-structural-errors.quickbase' + $joined = $output -join "`n" + + $joined | Should Match 'QB108\|error\|1:25\|Unclosed Quickbase query block\.' + $joined | Should Match 'QB104\|warning\|2:28\|Join\(\) argument 1 expects TextList but received Text\.' + $joined | Should Match 'QB001\|error\|2:54\|Expected \) to close the function call\.' + $joined | Should Match 'QB104\|warning\|3:25\|The not operator expects a Boolean value\.' + } +} \ No newline at end of file diff --git a/tests/QuickbaseGrammar.Tests.ps1 b/tests/QuickbaseGrammar.Tests.ps1 new file mode 100644 index 0000000..e120e6c --- /dev/null +++ b/tests/QuickbaseGrammar.Tests.ps1 @@ -0,0 +1,116 @@ +Set-StrictMode -Version Latest + +$grammarPath = Join-Path $PSScriptRoot '..\syntaxes\quickbase.tmLanguage.json' +$grammar = Get-Content $grammarPath -Raw | ConvertFrom-Json +$languageConfigurationPath = Join-Path $PSScriptRoot '..\language-configuration.json' +$languageConfigurationText = Get-Content $languageConfigurationPath -Raw + +Describe 'Quickbase grammar' { + It 'loads the TextMate grammar JSON' { + $grammar.scopeName | Should Be 'source.quickbase' + } + + It 'does not treat square-bracket references as editor bracket pairs' { + $languageConfigurationText | Should Not Match '"brackets"\s*:\s*\[[^\]]*\["\["\s*,\s*"\]"\]' + $languageConfigurationText | Should Match '"colorizedBracketPairs"' + } + + Context 'API query field IDs and special values' { + $queryBlockPattern = $grammar.repository.apiQuery.patterns[0] + $fieldIdPattern = $queryBlockPattern.patterns[0] + $specialQuotedValuePattern = $queryBlockPattern.patterns[3] + $specialBareValuePattern = $queryBlockPattern.patterns[4] + $queryJoinerPattern = $grammar.repository.apiQuery.patterns[3] + + It 'uses a visible block scope for query braces' { + $queryBlockPattern.beginCaptures.'0'.name | Should Be 'keyword.control.block.quickbase.query' + $queryBlockPattern.endCaptures.'0'.name | Should Be 'keyword.control.block.quickbase.query' + } + + It 'uses the string-safe query grammar inside quoted strings' { + $grammar.repository.strings.patterns[0].include | Should Be '#apiQueryString' + } + + It 'uses a variable scope for quoted or bare field IDs' { + $fieldIdPattern.name | Should Be 'variable.other.quickbase.query' + } + + It 'matches the field ID before the query operator' { + $sample = '{6.EX.today}' + $match = [regex]::Match($sample, $fieldIdPattern.match) + + $match.Success | Should Be $true + $match.Value | Should Be '6' + } + + It 'matches quoted special query values like today' { + $sample = "{'13'.EX.'today'}" + $match = [regex]::Match($sample, $specialQuotedValuePattern.match) + + $specialQuotedValuePattern.name | Should Be 'constant.language.quickbase.query.special' + $match.Success | Should Be $true + $match.Value | Should Be "'today'" + } + + It 'matches relative date inserts like -1 days ago' { + $sample = "{'13'.EX.'-1 days ago'}" + $match = [regex]::Match($sample, $specialQuotedValuePattern.match) + + $match.Success | Should Be $true + $match.Value | Should Be "'-1 days ago'" + } + + It 'matches bare special query values like today when unquoted' { + $sample = "{'6'.EX.today}" + $match = [regex]::Match($sample, $specialBareValuePattern.match) + + $specialBareValuePattern.name | Should Be 'constant.language.quickbase.query.special' + $match.Success | Should Be $true + $match.Value | Should Be 'today' + } + + It 'matches logical joiners between query blocks' { + $sample = "{'26'.EX.'1'}AND{'11'.EX.'1'}" + $match = [regex]::Match($sample, $queryJoinerPattern.match) + + $queryJoinerPattern.name | Should Be 'keyword.control.quickbase.query' + $match.Success | Should Be $true + $match.Value | Should Be 'AND' + } + } + + Context 'variable declarations' { + $varKeywordPattern = $grammar.repository.storage.patterns[0] + $bracketReferencePattern = $grammar.repository.variable.patterns[0] + $variableDeclarationPattern = $grammar.repository.variable.patterns[1] + + It 'gives var a dedicated keyword scope' { + $varKeywordPattern.name | Should Be 'keyword.control.declaration.quickbase' + $variableDeclarationPattern.captures.'1'.name | Should Be 'keyword.control.declaration.quickbase' + } + + It 'captures var, the declared type, and the variable name separately' { + $sample = 'var Text queryCERS' + $match = [regex]::Match($sample, $variableDeclarationPattern.match) + + $match.Success | Should Be $true + $match.Groups[1].Value | Should Be 'var' + $match.Groups[2].Value | Should Be 'Text' + $match.Groups[3].Value | Should Be 'queryCERS' + } + + It 'uses a type-oriented scope for the declared datatype' { + $variableDeclarationPattern.captures.'2'.name | Should Be 'entity.name.type.quickbase' + $variableDeclarationPattern.captures.'3'.name | Should Be 'variable.other.quickbase' + } + + It 'scopes bracketed field and table references like variable names' { + $sample = '[_DBID_RECERTIFICATIONS___RECERTS]' + $match = [regex]::Match($sample, $bracketReferencePattern.match) + + $bracketReferencePattern.name | Should Be 'variable.other.quickbase' + $match.Success | Should Be $true + $match.Value | Should Be '[_DBID_RECERTIFICATIONS___RECERTS]' + } + } +} \ No newline at end of file diff --git a/tests/QuickbaseHover.Tests.ps1 b/tests/QuickbaseHover.Tests.ps1 new file mode 100644 index 0000000..f4c666f --- /dev/null +++ b/tests/QuickbaseHover.Tests.ps1 @@ -0,0 +1,83 @@ +Set-StrictMode -Version Latest + +function Invoke-QuickbaseHover { + param( + [Parameter(Mandatory = $true)] + [string]$FixtureName, + + [Parameter(Mandatory = $true)] + [int]$Line, + + [Parameter(Mandatory = $true)] + [int]$Character + ) + + @(cscript //nologo .\tests\RunQuickbaseHover.js (Join-Path .\tests\fixtures $FixtureName) $Line $Character) +} + +Describe 'Quickbase hover documentation' { + It 'describes query operators' { + $output = Invoke-QuickbaseHover -FixtureName 'hover-reference.quickbase' -Line 1 -Character 22 + $joined = $output -join "`n" + + $joined | Should Match '^KIND\|queryOperator' + $joined | Should Match 'LABEL\|EX' + $joined | Should Match 'SUMMARY\|Matches records where the field is equal to the target value\.' + } + + It 'describes variable types' { + $output = Invoke-QuickbaseHover -FixtureName 'hover-reference.quickbase' -Line 2 -Character 5 + $joined = $output -join "`n" + + $joined | Should Match '^KIND\|variableType' + $joined | Should Match 'LABEL\|RecordList' + $joined | Should Match 'SUMMARY\|Intermediate list of records, typically consumed by formula-query functions\.' + } + + It 'describes variable references using the declared type' { + $output = Invoke-QuickbaseHover -FixtureName 'hover-reference.quickbase' -Line 2 -Character 35 + $joined = $output -join "`n" + + $joined | Should Match '^KIND\|variableReference' + $joined | Should Match 'LABEL\|\$query' + $joined | Should Match 'SUMMARY\|Declared as Text\.' + } + + It 'describes formula-query functions' { + $output = Invoke-QuickbaseHover -FixtureName 'hover-reference.quickbase' -Line 2 -Character 23 + $joined = $output -join "`n" + + $joined | Should Match '^KIND\|function' + $joined | Should Match 'LABEL\|GetRecords' + $joined | Should Match 'SIGNATURE\|GetRecords\(Text\)' + $joined | Should Match 'SIGNATURE\|GetRecords\(Text, Table\)' + $joined | Should Match 'SUMMARY\|Returns a RecordList of records that match a Quickbase query string\.' + } + + It 'describes quoted today query inserts' { + $output = Invoke-QuickbaseHover -FixtureName 'hover-reference.quickbase' -Line 5 -Character 35 + $joined = $output -join "`n" + + $joined | Should Match '^KIND\|querySpecialValue' + $joined | Should Match "LABEL\|'today'" + $joined | Should Match "SUMMARY\|Special date query value that resolves to today's date for Date and Date/Time filters\." + } + + It 'describes relative date query inserts' { + $output = Invoke-QuickbaseHover -FixtureName 'hover-reference.quickbase' -Line 6 -Character 40 + $joined = $output -join "`n" + + $joined | Should Match '^KIND\|querySpecialValue' + $joined | Should Match "LABEL\|'-1 days ago'" + $joined | Should Match "SUMMARY\|Relative date query value such as '2 days ago' or '-1 days ago'\." + } + + It 'describes current-user query inserts' { + $output = Invoke-QuickbaseHover -FixtureName 'hover-reference.quickbase' -Line 7 -Character 36 + $joined = $output -join "`n" + + $joined | Should Match '^KIND\|querySpecialValue' + $joined | Should Match "LABEL\|'_curuser_'" + $joined | Should Match 'SUMMARY\|Special user query value that resolves to the current user\.' + } +} \ No newline at end of file diff --git a/tests/QuickbaseTokenization.Tests.ps1 b/tests/QuickbaseTokenization.Tests.ps1 new file mode 100644 index 0000000..b6f3a24 --- /dev/null +++ b/tests/QuickbaseTokenization.Tests.ps1 @@ -0,0 +1,9 @@ +Set-StrictMode -Version Latest + +Describe 'Quickbase grammar snapshots' { + It 'matches committed snapshots for representative .quickbase fixtures' { + $runner = Join-Path $PSScriptRoot 'Invoke-QuickbaseTokenization.ps1' + + & $runner verify + } +} diff --git a/tests/RunQuickbaseDiagnostics.js b/tests/RunQuickbaseDiagnostics.js new file mode 100644 index 0000000..907cb9b --- /dev/null +++ b/tests/RunQuickbaseDiagnostics.js @@ -0,0 +1,44 @@ +var fso = new ActiveXObject('Scripting.FileSystemObject'); +var scriptDirectory = fso.GetParentFolderName(WScript.ScriptFullName); + +function readText(path) { + var stream = fso.OpenTextFile(path, 1, false); + var content = stream.ReadAll(); + stream.Close(); + return content; +} + +function include(relativePath) { + eval(readText(fso.BuildPath(scriptDirectory, relativePath))); +} + +try { + include('..\\lib\\quickbase-reference.js'); + include('..\\lib\\quickbase-language-service.js'); + + var snippets = eval('(' + readText(fso.BuildPath(scriptDirectory, '..\\snippets\\snippets.json')) + ')'); + var catalog = QuickbaseLanguageService.buildFunctionCatalog(snippets, QuickbaseReference); + var targetPath = fso.GetAbsolutePathName(WScript.Arguments.Item(0)); + var source = readText(targetPath); + var validation = QuickbaseLanguageService.validateText(source, catalog); + var diagnostics = validation.diagnostics; + var index; + var item; + + if (!diagnostics.length) { + WScript.Echo('OK'); + WScript.Quit(0); + } + + for (index = 0; index < diagnostics.length; index += 1) { + item = diagnostics[index]; + WScript.Echo( + item.code + '|' + item.severity + '|' + + (item.startLine + 1) + ':' + (item.startCharacter + 1) + '|' + + item.message + ); + } +} catch (error) { + WScript.Echo('HARNESS|' + error.message); + WScript.Quit(1); +} diff --git a/tests/RunQuickbaseHover.js b/tests/RunQuickbaseHover.js new file mode 100644 index 0000000..2e4310a --- /dev/null +++ b/tests/RunQuickbaseHover.js @@ -0,0 +1,75 @@ +var fso = new ActiveXObject('Scripting.FileSystemObject'); +var scriptDirectory = fso.GetParentFolderName(WScript.ScriptFullName); + +function readText(path) { + var stream = fso.OpenTextFile(path, 1, false); + var content = stream.ReadAll(); + stream.Close(); + return content; +} + +function include(relativePath) { + eval(readText(fso.BuildPath(scriptDirectory, relativePath))); +} + +function offsetAt(text, line, character) { + var currentLine = 1; + var currentCharacter = 1; + var index; + + for (index = 0; index < text.length; index += 1) { + if (currentLine === line && currentCharacter === character) { + return index; + } + if (text.charAt(index) === '\n') { + currentLine += 1; + currentCharacter = 1; + } else { + currentCharacter += 1; + } + } + + return text.length; +} + +try { + include('..\\lib\\quickbase-reference.js'); + include('..\\lib\\quickbase-language-service.js'); + include('..\\lib\\quickbase-hover-service.js'); + + var snippets = eval('(' + readText(fso.BuildPath(scriptDirectory, '..\\snippets\\snippets.json')) + ')'); + var catalog = QuickbaseLanguageService.buildFunctionCatalog(snippets, QuickbaseReference); + var targetPath = fso.GetAbsolutePathName(WScript.Arguments.Item(0)); + var line = parseInt(WScript.Arguments.Item(1), 10); + var character = parseInt(WScript.Arguments.Item(2), 10); + var source = readText(targetPath); + var hover = QuickbaseHoverService.getHoverData(source, offsetAt(source, line, character), catalog, QuickbaseReference); + var index; + + if (!hover) { + WScript.Echo('NONE'); + WScript.Quit(0); + } + + WScript.Echo('KIND|' + hover.kind); + WScript.Echo('LABEL|' + hover.label); + if (hover.summary) { + WScript.Echo('SUMMARY|' + hover.summary); + } + if (hover.signatures) { + for (index = 0; index < hover.signatures.length; index += 1) { + WScript.Echo('SIGNATURE|' + hover.signatures[index]); + } + } + if (hover.notes) { + for (index = 0; index < hover.notes.length; index += 1) { + WScript.Echo('NOTE|' + hover.notes[index]); + } + } + if (hover.docsUrl) { + WScript.Echo('URL|' + hover.docsUrl); + } +} catch (error) { + WScript.Echo('HARNESS|' + error.message); + WScript.Quit(1); +} diff --git a/tests/fixtures/concatenated-query.quickbase b/tests/fixtures/concatenated-query.quickbase new file mode 100644 index 0000000..6592e10 --- /dev/null +++ b/tests/fixtures/concatenated-query.quickbase @@ -0,0 +1 @@ +var RecordList ownerProjects = GetRecords("{6.EX.'" & [Manager] & "'}", [_DBID_PROJECTS]); \ No newline at end of file diff --git a/tests/fixtures/declarations-and-queries.quickbase b/tests/fixtures/declarations-and-queries.quickbase new file mode 100644 index 0000000..b82584c --- /dev/null +++ b/tests/fixtures/declarations-and-queries.quickbase @@ -0,0 +1,2 @@ +var Text queryCERS = "{'6'.EX.'1'}OR{'7'.GT.'5'}"; +var Text queryByOwner = "{'8'.EX.'{{ownerId}}'}"; diff --git a/tests/fixtures/diagnostics-newline-separated.quickbase b/tests/fixtures/diagnostics-newline-separated.quickbase new file mode 100644 index 0000000..b8a0cff --- /dev/null +++ b/tests/fixtures/diagnostics-newline-separated.quickbase @@ -0,0 +1,3 @@ +var TextList recs = Split("A;B", ";"); +Join($recs, "; ") +var Text todayquery = "{'13'.EX.'today'}"; \ No newline at end of file diff --git a/tests/fixtures/diagnostics-semantic-errors.quickbase b/tests/fixtures/diagnostics-semantic-errors.quickbase new file mode 100644 index 0000000..e3322b8 --- /dev/null +++ b/tests/fixtures/diagnostics-semantic-errors.quickbase @@ -0,0 +1,8 @@ +var Text ownerSummary = Join("A", 5); +var Number ownerCount = Join(List(", ", "A", "B"), ", "); +var Text query = "{'6'.BAD.'1'}"; +var Number query = 7; +var Text ownerName = $missingOwner; +var Text query1 = "{6.ex.'1'}"; +var Text pattern = "[A-Z]+"; +var Bool hasOwnerCode = RegexMatch("ABC", $pattern); \ No newline at end of file diff --git a/tests/fixtures/diagnostics-standalone-query.quickbase b/tests/fixtures/diagnostics-standalone-query.quickbase new file mode 100644 index 0000000..541834f --- /dev/null +++ b/tests/fixtures/diagnostics-standalone-query.quickbase @@ -0,0 +1 @@ +{'26'.EX.'1'}AND{'11'.EX.'1'} \ No newline at end of file diff --git a/tests/fixtures/diagnostics-structural-errors.quickbase b/tests/fixtures/diagnostics-structural-errors.quickbase new file mode 100644 index 0000000..63bb508 --- /dev/null +++ b/tests/fixtures/diagnostics-structural-errors.quickbase @@ -0,0 +1,3 @@ +var Text brokenQuery = "{'6'.EX.'1'"; +var Text brokenCall = Join(List(", ", "A", "B"), "; "; +var Bool badLogic = not "yes"; diff --git a/tests/fixtures/diagnostics-valid.quickbase b/tests/fixtures/diagnostics-valid.quickbase new file mode 100644 index 0000000..cc593eb --- /dev/null +++ b/tests/fixtures/diagnostics-valid.quickbase @@ -0,0 +1,11 @@ +var Text statusQuery = "{'6'.EX.'In Progress'}"; +var Text todayQuery = "{'13'.EX.today}"; +var Text currentUserQuery = "{'20'.TV._curuser_}"; +var RecordList openProjects = GetRecords($statusQuery, [_DBID_PROJECTS]); +var RecordList ownerProjects = GetRecords("{6.EX.'" & [Manager] & "'}", [_DBID_PROJECTS]); +var TextList owners = GetFieldValues($openProjects, 8); +var Text ownerSummary = Join($owners, "; "); +var Number ownerCount = Size($owners); +var Bool hasOwners = not IsNull($ownerSummary) and $ownerCount > 0; + +$ownerSummary diff --git a/tests/fixtures/fields-and-booleans.quickbase b/tests/fixtures/fields-and-booleans.quickbase new file mode 100644 index 0000000..81cce1d --- /dev/null +++ b/tests/fixtures/fields-and-booleans.quickbase @@ -0,0 +1 @@ +var Bool isActive = [Status] = "Active" and not IsNull([Assigned To]); diff --git a/tests/fixtures/formula-functions.quickbase b/tests/fixtures/formula-functions.quickbase new file mode 100644 index 0000000..24255c8 --- /dev/null +++ b/tests/fixtures/formula-functions.quickbase @@ -0,0 +1,5 @@ +// Retrieve open project owners +var RecordList openProjects = GetRecords("{'6'.EX.'In Progress'}", [_DBID_PROJECTS]); +var TextList owners = GetFieldValues($openProjects, 8); + +Join($owners, "; ") diff --git a/tests/fixtures/hover-reference.quickbase b/tests/fixtures/hover-reference.quickbase new file mode 100644 index 0000000..174e466 --- /dev/null +++ b/tests/fixtures/hover-reference.quickbase @@ -0,0 +1,7 @@ +var Text query = "{6.EX.today}"; +var RecordList recs = GetRecords($query, [_DBID_PROJECTS]); +var Bool hasMatch = RegexMatch("ABC", "[A-Z]+"); +Join(GetFieldValues($recs, 8), "; ") +var Text todayquery = "{'13'.EX.'today'}"; +var Text futurequery = "{'13'.EX.'-1 days ago'}"; +var Text ownerquery = "{'20'.TV.'_curuser_'}"; \ No newline at end of file diff --git a/tests/fixtures/standalone-query.quickbase b/tests/fixtures/standalone-query.quickbase new file mode 100644 index 0000000..d97ee41 --- /dev/null +++ b/tests/fixtures/standalone-query.quickbase @@ -0,0 +1,2 @@ +{'26'.EX.'1'}AND{'11'.EX.'1'} +{'13'.EX.'today'} OR {'20'.TV.'_curuser_'} \ No newline at end of file diff --git a/tests/snapshots/concatenated-query.snapshot.json b/tests/snapshots/concatenated-query.snapshot.json new file mode 100644 index 0000000..331372c --- /dev/null +++ b/tests/snapshots/concatenated-query.snapshot.json @@ -0,0 +1,75 @@ +{ + "line": 1, + "text": "var RecordList ownerProjects = GetRecords(\"{6.EX.\u0027\" \u0026 [Manager] \u0026 \"\u0027}\", [_DBID_PROJECTS]);", + "topLevel": [ + { + "start": 0, + "end": 3, + "text": "var", + "scope": "keyword.control.declaration.quickbase" + }, + { + "start": 4, + "end": 14, + "text": "RecordList", + "scope": "entity.name.type.quickbase" + }, + { + "start": 15, + "end": 28, + "text": "ownerProjects", + "scope": "variable.other.quickbase" + }, + { + "start": 29, + "end": 30, + "text": "=", + "scope": "keyword.operator.quickbase" + }, + { + "start": 31, + "end": 41, + "text": "GetRecords", + "scope": "support.function.quickbase" + }, + { + "start": 42, + "end": 51, + "text": "\"{6.EX.\u0027\"", + "scope": "string.quoted.double.quickbase" + }, + { + "start": 52, + "end": 53, + "text": "\u0026", + "scope": "keyword.operator.quickbase" + }, + { + "start": 54, + "end": 63, + "text": "[Manager]", + "scope": "variable.other.quickbase" + }, + { + "start": 64, + "end": 65, + "text": "\u0026", + "scope": "keyword.operator.quickbase" + }, + { + "start": 66, + "end": 70, + "text": "\"\u0027}\"", + "scope": "string.quoted.double.quickbase" + }, + { + "start": 72, + "end": 88, + "text": "[_DBID_PROJECTS]", + "scope": "variable.other.quickbase" + } + ], + "queries": [ + + ] +} diff --git a/tests/snapshots/declarations-and-queries.snapshot.json b/tests/snapshots/declarations-and-queries.snapshot.json new file mode 100644 index 0000000..1fb8a91 --- /dev/null +++ b/tests/snapshots/declarations-and-queries.snapshot.json @@ -0,0 +1,231 @@ +[ + { + "line": 1, + "text": "var Text queryCERS = \"{\u00276\u0027.EX.\u00271\u0027}OR{\u00277\u0027.GT.\u00275\u0027}\";", + "topLevel": [ + { + "start": 0, + "end": 3, + "text": "var", + "scope": "keyword.control.declaration.quickbase" + }, + { + "start": 4, + "end": 8, + "text": "Text", + "scope": "entity.name.type.quickbase" + }, + { + "start": 9, + "end": 18, + "text": "queryCERS", + "scope": "variable.other.quickbase" + }, + { + "start": 19, + "end": 20, + "text": "=", + "scope": "keyword.operator.quickbase" + }, + { + "start": 21, + "end": 49, + "text": "\"{\u00276\u0027.EX.\u00271\u0027}OR{\u00277\u0027.GT.\u00275\u0027}\"", + "scope": "string.quoted.double.quickbase" + } + ], + "queries": [ + { + "start": 22, + "end": 34, + "text": "{\u00276\u0027.EX.\u00271\u0027}", + "tokens": [ + { + "start": 22, + "end": 23, + "text": "{", + "scope": "keyword.control.block.quickbase.query" + }, + { + "start": 23, + "end": 26, + "text": "\u00276\u0027", + "scope": "variable.other.quickbase.query" + }, + { + "start": 26, + "end": 27, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 27, + "end": 29, + "text": "EX", + "scope": "keyword.operator.comparison.quickbase.query" + }, + { + "start": 29, + "end": 30, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 30, + "end": 33, + "text": "\u00271\u0027", + "scope": "string.quoted.single.quickbase.query" + }, + { + "start": 33, + "end": 34, + "text": "}", + "scope": "keyword.control.block.quickbase.query" + } + ] + }, + { + "start": 36, + "end": 48, + "text": "{\u00277\u0027.GT.\u00275\u0027}", + "tokens": [ + { + "start": 36, + "end": 37, + "text": "{", + "scope": "keyword.control.block.quickbase.query" + }, + { + "start": 37, + "end": 40, + "text": "\u00277\u0027", + "scope": "variable.other.quickbase.query" + }, + { + "start": 40, + "end": 41, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 41, + "end": 43, + "text": "GT", + "scope": "keyword.operator.comparison.quickbase.query" + }, + { + "start": 43, + "end": 44, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 44, + "end": 47, + "text": "\u00275\u0027", + "scope": "string.quoted.single.quickbase.query" + }, + { + "start": 47, + "end": 48, + "text": "}", + "scope": "keyword.control.block.quickbase.query" + } + ] + } + ] + }, + { + "line": 2, + "text": "var Text queryByOwner = \"{\u00278\u0027.EX.\u0027{{ownerId}}\u0027}\";", + "topLevel": [ + { + "start": 0, + "end": 3, + "text": "var", + "scope": "keyword.control.declaration.quickbase" + }, + { + "start": 4, + "end": 8, + "text": "Text", + "scope": "entity.name.type.quickbase" + }, + { + "start": 9, + "end": 21, + "text": "queryByOwner", + "scope": "variable.other.quickbase" + }, + { + "start": 22, + "end": 23, + "text": "=", + "scope": "keyword.operator.quickbase" + }, + { + "start": 24, + "end": 48, + "text": "\"{\u00278\u0027.EX.\u0027{{ownerId}}\u0027}\"", + "scope": "string.quoted.double.quickbase" + } + ], + "queries": [ + { + "start": 25, + "end": 47, + "text": "{\u00278\u0027.EX.\u0027{{ownerId}}\u0027}", + "tokens": [ + { + "start": 25, + "end": 26, + "text": "{", + "scope": "keyword.control.block.quickbase.query" + }, + { + "start": 26, + "end": 29, + "text": "\u00278\u0027", + "scope": "variable.other.quickbase.query" + }, + { + "start": 29, + "end": 30, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 30, + "end": 32, + "text": "EX", + "scope": "keyword.operator.comparison.quickbase.query" + }, + { + "start": 32, + "end": 33, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 33, + "end": 46, + "text": "\u0027{{ownerId}}\u0027", + "scope": "string.quoted.single.quickbase.query" + }, + { + "start": 34, + "end": 45, + "text": "{{ownerId}}", + "scope": "variable.other.quickbase.query" + }, + { + "start": 46, + "end": 47, + "text": "}", + "scope": "keyword.control.block.quickbase.query" + } + ] + } + ] + } +] diff --git a/tests/snapshots/fields-and-booleans.snapshot.json b/tests/snapshots/fields-and-booleans.snapshot.json new file mode 100644 index 0000000..f64706f --- /dev/null +++ b/tests/snapshots/fields-and-booleans.snapshot.json @@ -0,0 +1,75 @@ +{ + "line": 1, + "text": "var Bool isActive = [Status] = \"Active\" and not IsNull([Assigned To]);", + "topLevel": [ + { + "start": 0, + "end": 3, + "text": "var", + "scope": "keyword.control.declaration.quickbase" + }, + { + "start": 4, + "end": 8, + "text": "Bool", + "scope": "entity.name.type.quickbase" + }, + { + "start": 9, + "end": 17, + "text": "isActive", + "scope": "variable.other.quickbase" + }, + { + "start": 18, + "end": 19, + "text": "=", + "scope": "keyword.operator.quickbase" + }, + { + "start": 20, + "end": 28, + "text": "[Status]", + "scope": "variable.other.quickbase" + }, + { + "start": 29, + "end": 30, + "text": "=", + "scope": "keyword.operator.quickbase" + }, + { + "start": 31, + "end": 39, + "text": "\"Active\"", + "scope": "string.quoted.double.quickbase" + }, + { + "start": 40, + "end": 43, + "text": "and", + "scope": "keyword.control.quickbase" + }, + { + "start": 44, + "end": 47, + "text": "not", + "scope": "keyword.control.quickbase" + }, + { + "start": 48, + "end": 54, + "text": "IsNull", + "scope": "support.function.quickbase" + }, + { + "start": 55, + "end": 68, + "text": "[Assigned To]", + "scope": "variable.other.quickbase" + } + ], + "queries": [ + + ] +} diff --git a/tests/snapshots/formula-functions.snapshot.json b/tests/snapshots/formula-functions.snapshot.json new file mode 100644 index 0000000..ebfab8c --- /dev/null +++ b/tests/snapshots/formula-functions.snapshot.json @@ -0,0 +1,204 @@ +[ + { + "line": 1, + "text": "// Retrieve open project owners", + "topLevel": [ + { + "start": 0, + "end": 31, + "text": "// Retrieve open project owners", + "scope": "comment.line.quickbase" + } + ], + "queries": [ + + ] + }, + { + "line": 2, + "text": "var RecordList openProjects = GetRecords(\"{\u00276\u0027.EX.\u0027In Progress\u0027}\", [_DBID_PROJECTS]);", + "topLevel": [ + { + "start": 0, + "end": 3, + "text": "var", + "scope": "keyword.control.declaration.quickbase" + }, + { + "start": 4, + "end": 14, + "text": "RecordList", + "scope": "entity.name.type.quickbase" + }, + { + "start": 15, + "end": 27, + "text": "openProjects", + "scope": "variable.other.quickbase" + }, + { + "start": 28, + "end": 29, + "text": "=", + "scope": "keyword.operator.quickbase" + }, + { + "start": 30, + "end": 40, + "text": "GetRecords", + "scope": "support.function.quickbase" + }, + { + "start": 41, + "end": 65, + "text": "\"{\u00276\u0027.EX.\u0027In Progress\u0027}\"", + "scope": "string.quoted.double.quickbase" + }, + { + "start": 67, + "end": 83, + "text": "[_DBID_PROJECTS]", + "scope": "variable.other.quickbase" + } + ], + "queries": [ + { + "start": 42, + "end": 64, + "text": "{\u00276\u0027.EX.\u0027In Progress\u0027}", + "tokens": [ + { + "start": 42, + "end": 43, + "text": "{", + "scope": "keyword.control.block.quickbase.query" + }, + { + "start": 43, + "end": 46, + "text": "\u00276\u0027", + "scope": "variable.other.quickbase.query" + }, + { + "start": 46, + "end": 47, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 47, + "end": 49, + "text": "EX", + "scope": "keyword.operator.comparison.quickbase.query" + }, + { + "start": 49, + "end": 50, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 50, + "end": 63, + "text": "\u0027In Progress\u0027", + "scope": "string.quoted.single.quickbase.query" + }, + { + "start": 63, + "end": 64, + "text": "}", + "scope": "keyword.control.block.quickbase.query" + } + ] + } + ] + }, + { + "line": 3, + "text": "var TextList owners = GetFieldValues($openProjects, 8);", + "topLevel": [ + { + "start": 0, + "end": 3, + "text": "var", + "scope": "keyword.control.declaration.quickbase" + }, + { + "start": 4, + "end": 12, + "text": "TextList", + "scope": "entity.name.type.quickbase" + }, + { + "start": 13, + "end": 19, + "text": "owners", + "scope": "variable.other.quickbase" + }, + { + "start": 20, + "end": 21, + "text": "=", + "scope": "keyword.operator.quickbase" + }, + { + "start": 22, + "end": 36, + "text": "GetFieldValues", + "scope": "support.function.quickbase" + }, + { + "start": 37, + "end": 50, + "text": "$openProjects", + "scope": "variable.other.quickbase" + }, + { + "start": 52, + "end": 53, + "text": "8", + "scope": "constant.numeric.quickbase" + } + ], + "queries": [ + + ] + }, + { + "line": 4, + "text": "", + "topLevel": [ + + ], + "queries": [ + + ] + }, + { + "line": 5, + "text": "Join($owners, \"; \")", + "topLevel": [ + { + "start": 0, + "end": 4, + "text": "Join", + "scope": "support.function.quickbase" + }, + { + "start": 5, + "end": 12, + "text": "$owners", + "scope": "variable.other.quickbase" + }, + { + "start": 14, + "end": 18, + "text": "\"; \"", + "scope": "string.quoted.double.quickbase" + } + ], + "queries": [ + + ] + } +] diff --git a/tests/snapshots/hover-reference.snapshot.json b/tests/snapshots/hover-reference.snapshot.json new file mode 100644 index 0000000..f263357 --- /dev/null +++ b/tests/snapshots/hover-reference.snapshot.json @@ -0,0 +1,491 @@ +[ + { + "line": 1, + "text": "var Text query = \"{6.EX.today}\";", + "topLevel": [ + { + "start": 0, + "end": 3, + "text": "var", + "scope": "keyword.control.declaration.quickbase" + }, + { + "start": 4, + "end": 8, + "text": "Text", + "scope": "entity.name.type.quickbase" + }, + { + "start": 9, + "end": 14, + "text": "query", + "scope": "variable.other.quickbase" + }, + { + "start": 15, + "end": 16, + "text": "=", + "scope": "keyword.operator.quickbase" + }, + { + "start": 17, + "end": 31, + "text": "\"{6.EX.today}\"", + "scope": "string.quoted.double.quickbase" + } + ], + "queries": [ + { + "start": 18, + "end": 30, + "text": "{6.EX.today}", + "tokens": [ + { + "start": 18, + "end": 19, + "text": "{", + "scope": "keyword.control.block.quickbase.query" + }, + { + "start": 19, + "end": 20, + "text": "6", + "scope": "variable.other.quickbase.query" + }, + { + "start": 20, + "end": 21, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 21, + "end": 23, + "text": "EX", + "scope": "keyword.operator.comparison.quickbase.query" + }, + { + "start": 23, + "end": 24, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 24, + "end": 29, + "text": "today", + "scope": "constant.language.quickbase.query.special" + }, + { + "start": 29, + "end": 30, + "text": "}", + "scope": "keyword.control.block.quickbase.query" + } + ] + } + ] + }, + { + "line": 2, + "text": "var RecordList recs = GetRecords($query, [_DBID_PROJECTS]);", + "topLevel": [ + { + "start": 0, + "end": 3, + "text": "var", + "scope": "keyword.control.declaration.quickbase" + }, + { + "start": 4, + "end": 14, + "text": "RecordList", + "scope": "entity.name.type.quickbase" + }, + { + "start": 15, + "end": 19, + "text": "recs", + "scope": "variable.other.quickbase" + }, + { + "start": 20, + "end": 21, + "text": "=", + "scope": "keyword.operator.quickbase" + }, + { + "start": 22, + "end": 32, + "text": "GetRecords", + "scope": "support.function.quickbase" + }, + { + "start": 33, + "end": 39, + "text": "$query", + "scope": "variable.other.quickbase" + }, + { + "start": 41, + "end": 57, + "text": "[_DBID_PROJECTS]", + "scope": "variable.other.quickbase" + } + ], + "queries": [ + + ] + }, + { + "line": 3, + "text": "var Bool hasMatch = RegexMatch(\"ABC\", \"[A-Z]+\");", + "topLevel": [ + { + "start": 0, + "end": 3, + "text": "var", + "scope": "keyword.control.declaration.quickbase" + }, + { + "start": 4, + "end": 8, + "text": "Bool", + "scope": "entity.name.type.quickbase" + }, + { + "start": 9, + "end": 17, + "text": "hasMatch", + "scope": "variable.other.quickbase" + }, + { + "start": 18, + "end": 19, + "text": "=", + "scope": "keyword.operator.quickbase" + }, + { + "start": 20, + "end": 30, + "text": "RegexMatch", + "scope": "support.function.quickbase" + }, + { + "start": 31, + "end": 36, + "text": "\"ABC\"", + "scope": "string.quoted.double.quickbase" + }, + { + "start": 38, + "end": 46, + "text": "\"[A-Z]+\"", + "scope": "string.quoted.double.quickbase" + } + ], + "queries": [ + + ] + }, + { + "line": 4, + "text": "Join(GetFieldValues($recs, 8), \"; \")", + "topLevel": [ + { + "start": 0, + "end": 4, + "text": "Join", + "scope": "support.function.quickbase" + }, + { + "start": 5, + "end": 19, + "text": "GetFieldValues", + "scope": "support.function.quickbase" + }, + { + "start": 20, + "end": 25, + "text": "$recs", + "scope": "variable.other.quickbase" + }, + { + "start": 27, + "end": 28, + "text": "8", + "scope": "constant.numeric.quickbase" + }, + { + "start": 31, + "end": 35, + "text": "\"; \"", + "scope": "string.quoted.double.quickbase" + } + ], + "queries": [ + + ] + }, + { + "line": 5, + "text": "var Text todayquery = \"{\u002713\u0027.EX.\u0027today\u0027}\";", + "topLevel": [ + { + "start": 0, + "end": 3, + "text": "var", + "scope": "keyword.control.declaration.quickbase" + }, + { + "start": 4, + "end": 8, + "text": "Text", + "scope": "entity.name.type.quickbase" + }, + { + "start": 9, + "end": 19, + "text": "todayquery", + "scope": "variable.other.quickbase" + }, + { + "start": 20, + "end": 21, + "text": "=", + "scope": "keyword.operator.quickbase" + }, + { + "start": 22, + "end": 41, + "text": "\"{\u002713\u0027.EX.\u0027today\u0027}\"", + "scope": "string.quoted.double.quickbase" + } + ], + "queries": [ + { + "start": 23, + "end": 40, + "text": "{\u002713\u0027.EX.\u0027today\u0027}", + "tokens": [ + { + "start": 23, + "end": 24, + "text": "{", + "scope": "keyword.control.block.quickbase.query" + }, + { + "start": 24, + "end": 28, + "text": "\u002713\u0027", + "scope": "variable.other.quickbase.query" + }, + { + "start": 28, + "end": 29, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 29, + "end": 31, + "text": "EX", + "scope": "keyword.operator.comparison.quickbase.query" + }, + { + "start": 31, + "end": 32, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 32, + "end": 39, + "text": "\u0027today\u0027", + "scope": "constant.language.quickbase.query.special" + }, + { + "start": 39, + "end": 40, + "text": "}", + "scope": "keyword.control.block.quickbase.query" + } + ] + } + ] + }, + { + "line": 6, + "text": "var Text futurequery = \"{\u002713\u0027.EX.\u0027-1 days ago\u0027}\";", + "topLevel": [ + { + "start": 0, + "end": 3, + "text": "var", + "scope": "keyword.control.declaration.quickbase" + }, + { + "start": 4, + "end": 8, + "text": "Text", + "scope": "entity.name.type.quickbase" + }, + { + "start": 9, + "end": 20, + "text": "futurequery", + "scope": "variable.other.quickbase" + }, + { + "start": 21, + "end": 22, + "text": "=", + "scope": "keyword.operator.quickbase" + }, + { + "start": 23, + "end": 48, + "text": "\"{\u002713\u0027.EX.\u0027-1 days ago\u0027}\"", + "scope": "string.quoted.double.quickbase" + } + ], + "queries": [ + { + "start": 24, + "end": 47, + "text": "{\u002713\u0027.EX.\u0027-1 days ago\u0027}", + "tokens": [ + { + "start": 24, + "end": 25, + "text": "{", + "scope": "keyword.control.block.quickbase.query" + }, + { + "start": 25, + "end": 29, + "text": "\u002713\u0027", + "scope": "variable.other.quickbase.query" + }, + { + "start": 29, + "end": 30, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 30, + "end": 32, + "text": "EX", + "scope": "keyword.operator.comparison.quickbase.query" + }, + { + "start": 32, + "end": 33, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 33, + "end": 46, + "text": "\u0027-1 days ago\u0027", + "scope": "constant.language.quickbase.query.special" + }, + { + "start": 46, + "end": 47, + "text": "}", + "scope": "keyword.control.block.quickbase.query" + } + ] + } + ] + }, + { + "line": 7, + "text": "var Text ownerquery = \"{\u002720\u0027.TV.\u0027_curuser_\u0027}\";", + "topLevel": [ + { + "start": 0, + "end": 3, + "text": "var", + "scope": "keyword.control.declaration.quickbase" + }, + { + "start": 4, + "end": 8, + "text": "Text", + "scope": "entity.name.type.quickbase" + }, + { + "start": 9, + "end": 19, + "text": "ownerquery", + "scope": "variable.other.quickbase" + }, + { + "start": 20, + "end": 21, + "text": "=", + "scope": "keyword.operator.quickbase" + }, + { + "start": 22, + "end": 45, + "text": "\"{\u002720\u0027.TV.\u0027_curuser_\u0027}\"", + "scope": "string.quoted.double.quickbase" + } + ], + "queries": [ + { + "start": 23, + "end": 44, + "text": "{\u002720\u0027.TV.\u0027_curuser_\u0027}", + "tokens": [ + { + "start": 23, + "end": 24, + "text": "{", + "scope": "keyword.control.block.quickbase.query" + }, + { + "start": 24, + "end": 28, + "text": "\u002720\u0027", + "scope": "variable.other.quickbase.query" + }, + { + "start": 28, + "end": 29, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 29, + "end": 31, + "text": "TV", + "scope": "keyword.operator.comparison.quickbase.query" + }, + { + "start": 31, + "end": 32, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 32, + "end": 43, + "text": "\u0027_curuser_\u0027", + "scope": "constant.language.quickbase.query.special" + }, + { + "start": 43, + "end": 44, + "text": "}", + "scope": "keyword.control.block.quickbase.query" + } + ] + } + ] + } +] diff --git a/tests/snapshots/standalone-query.snapshot.json b/tests/snapshots/standalone-query.snapshot.json new file mode 100644 index 0000000..f05b148 --- /dev/null +++ b/tests/snapshots/standalone-query.snapshot.json @@ -0,0 +1,262 @@ +[ + { + "line": 1, + "text": "{\u002726\u0027.EX.\u00271\u0027}AND{\u002711\u0027.EX.\u00271\u0027}", + "topLevel": [ + { + "start": 2, + "end": 4, + "text": "26", + "scope": "constant.numeric.quickbase" + }, + { + "start": 10, + "end": 11, + "text": "1", + "scope": "constant.numeric.quickbase" + }, + { + "start": 13, + "end": 16, + "text": "AND", + "scope": "keyword.control.quickbase" + }, + { + "start": 18, + "end": 20, + "text": "11", + "scope": "constant.numeric.quickbase" + }, + { + "start": 26, + "end": 27, + "text": "1", + "scope": "constant.numeric.quickbase" + } + ], + "queries": [ + { + "start": 0, + "end": 13, + "text": "{\u002726\u0027.EX.\u00271\u0027}", + "tokens": [ + { + "start": 0, + "end": 1, + "text": "{", + "scope": "keyword.control.block.quickbase.query" + }, + { + "start": 1, + "end": 5, + "text": "\u002726\u0027", + "scope": "variable.other.quickbase.query" + }, + { + "start": 5, + "end": 6, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 6, + "end": 8, + "text": "EX", + "scope": "keyword.operator.comparison.quickbase.query" + }, + { + "start": 8, + "end": 9, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 9, + "end": 12, + "text": "\u00271\u0027", + "scope": "string.quoted.single.quickbase.query" + }, + { + "start": 12, + "end": 13, + "text": "}", + "scope": "keyword.control.block.quickbase.query" + } + ] + }, + { + "start": 16, + "end": 29, + "text": "{\u002711\u0027.EX.\u00271\u0027}", + "tokens": [ + { + "start": 16, + "end": 17, + "text": "{", + "scope": "keyword.control.block.quickbase.query" + }, + { + "start": 17, + "end": 21, + "text": "\u002711\u0027", + "scope": "variable.other.quickbase.query" + }, + { + "start": 21, + "end": 22, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 22, + "end": 24, + "text": "EX", + "scope": "keyword.operator.comparison.quickbase.query" + }, + { + "start": 24, + "end": 25, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 25, + "end": 28, + "text": "\u00271\u0027", + "scope": "string.quoted.single.quickbase.query" + }, + { + "start": 28, + "end": 29, + "text": "}", + "scope": "keyword.control.block.quickbase.query" + } + ] + } + ] + }, + { + "line": 2, + "text": "{\u002713\u0027.EX.\u0027today\u0027} OR {\u002720\u0027.TV.\u0027_curuser_\u0027}", + "topLevel": [ + { + "start": 2, + "end": 4, + "text": "13", + "scope": "constant.numeric.quickbase" + }, + { + "start": 18, + "end": 20, + "text": "OR", + "scope": "keyword.control.quickbase" + }, + { + "start": 23, + "end": 25, + "text": "20", + "scope": "constant.numeric.quickbase" + } + ], + "queries": [ + { + "start": 0, + "end": 17, + "text": "{\u002713\u0027.EX.\u0027today\u0027}", + "tokens": [ + { + "start": 0, + "end": 1, + "text": "{", + "scope": "keyword.control.block.quickbase.query" + }, + { + "start": 1, + "end": 5, + "text": "\u002713\u0027", + "scope": "variable.other.quickbase.query" + }, + { + "start": 5, + "end": 6, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 6, + "end": 8, + "text": "EX", + "scope": "keyword.operator.comparison.quickbase.query" + }, + { + "start": 8, + "end": 9, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 9, + "end": 16, + "text": "\u0027today\u0027", + "scope": "constant.language.quickbase.query.special" + }, + { + "start": 16, + "end": 17, + "text": "}", + "scope": "keyword.control.block.quickbase.query" + } + ] + }, + { + "start": 21, + "end": 42, + "text": "{\u002720\u0027.TV.\u0027_curuser_\u0027}", + "tokens": [ + { + "start": 21, + "end": 22, + "text": "{", + "scope": "keyword.control.block.quickbase.query" + }, + { + "start": 22, + "end": 26, + "text": "\u002720\u0027", + "scope": "variable.other.quickbase.query" + }, + { + "start": 26, + "end": 27, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 27, + "end": 29, + "text": "TV", + "scope": "keyword.operator.comparison.quickbase.query" + }, + { + "start": 29, + "end": 30, + "text": ".", + "scope": "punctuation.separator.dot.quickbase.query" + }, + { + "start": 30, + "end": 41, + "text": "\u0027_curuser_\u0027", + "scope": "constant.language.quickbase.query.special" + }, + { + "start": 41, + "end": 42, + "text": "}", + "scope": "keyword.control.block.quickbase.query" + } + ] + } + ] + } +]