From 652f3cc3fed28b8a3bcee334e556dacb790ac860 Mon Sep 17 00:00:00 2001 From: Sid Date: Wed, 1 Apr 2026 16:04:34 +0530 Subject: [PATCH] feat: basic annotation syntax support for lang (#7609) * chore: basic annotation support * chore: string and escape cases * feat: add basic multiline support * fix: simplify dedentation logic in annotation multiline text block * chore: fix asserts * feat: add annotation support to env and collection * feat: enhance annotation parsing with support for quoted arguments and nested parentheses * refactor: feedback, remove inline annotations * feat: move serializeAnnotations function to utils and update imports --- packages/bruno-lang/v2/src/bruToJson.js | 115 ++- .../bruno-lang/v2/src/collectionBruToJson.js | 90 +- packages/bruno-lang/v2/src/envToJson.js | 83 +- packages/bruno-lang/v2/src/jsonToBru.js | 45 +- .../bruno-lang/v2/src/jsonToCollectionBru.js | 26 +- packages/bruno-lang/v2/src/jsonToEnv.js | 6 +- packages/bruno-lang/v2/src/utils.js | 19 +- .../bruno-lang/v2/tests/annotations.spec.js | 833 ++++++++++++++++++ .../v2/tests/fixtures/annotations.bru | 6 + 9 files changed, 1143 insertions(+), 80 deletions(-) create mode 100644 packages/bruno-lang/v2/tests/annotations.spec.js create mode 100644 packages/bruno-lang/v2/tests/fixtures/annotations.bru diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index a6b2b4d2b..ecab5eee6 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -3,6 +3,10 @@ const _ = require('lodash'); const { safeParseJson, outdentString } = require('./utils'); const parseExample = require('./example/bruToJson'); +// this is done to avoid breaking existing pairlist mapping so +// the key is hidden and not added into the json automatically +const ANNOTATIONS_KEY = Symbol('annotations'); + /** * A Bru file is made up of blocks. * There are three types of blocks @@ -55,10 +59,27 @@ const grammar = ohm.grammar(`Bru { multilinetextblock = multilinetextblockdelimiter (~multilinetextblockdelimiter any)* multilinetextblockdelimiter st* contenttypeannotation? contenttypeannotation = "@contentType(" (~")" any)* ")" + // Annotation support (decorators on pairs) + annotationname = annotationchar+ + annotationchar = ~("(" | ")" | " " | "\\t" | "\\r" | "\\n" | ":") any + annotationsinglequotedargchar = ~"'" any + annotationsinglequotedarg = "'" annotationsinglequotedargchar* "'" + annotationdoublequotedargchar = ~"\\"" any + annotationdoublequotedarg = "\\"" annotationdoublequotedargchar* "\\"" + annotationunquotedargchar = ~")" any + annotationunquotedarg = annotationunquotedargchar* + annotationargvalue = annotationsinglequotedarg | annotationdoublequotedarg | annotationunquotedarg + annotationmultilinetextblock = multilinetextblockdelimiter (~multilinetextblockdelimiter any)* multilinetextblockdelimiter + annotationargscontents = annotationmultilinetextblock | annotationargvalue + annotationargs = "(" annotationargscontents ")" + annotation = "@" annotationname annotationargs? + annotationentry = st* annotation ~":" st* nl + pairannotations = annotationentry* + // Dictionary Blocks - dictionary = st* "{" pairlist? tagend + dictionary = st* "{" st* pairlist? tagend pairlist = optionalnl* pair (~tagend stnl* pair)* (~tagend space)* - pair = st* (quoted_key | key) st* ":" st* value st* + pair = st* pairannotations st* (quoted_key | key) st* ":" st* value st* disable_char = "~" quote_char = "\\"" esc_char = "\\\\" @@ -72,7 +93,7 @@ const grammar = ohm.grammar(`Bru { // Dictionary for Assert Block assertdictionary = st* "{" assertpairlist? tagend assertpairlist = optionalnl* assertpair (~tagend stnl* assertpair)* (~tagend space)* - assertpair = st* assertkey st* ":" st* value st* + assertpair = st* pairannotations st* assertkey st* ":" st* value st* assertkey = ~tagend assertkeychar* assertkeychar = ~(tagend | nl | ":") any @@ -168,12 +189,12 @@ const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => { return _.map(pairList[0], (pair) => { let name = _.keys(pair)[0]; let value = pair[name]; + const rawAnnotations = pair[ANNOTATIONS_KEY]; if (!parseEnabled) { - return { - name, - value - }; + const result = { name, value }; + if (rawAnnotations && rawAnnotations.length) result.annotations = rawAnnotations; + return result; } let enabled = true; @@ -182,11 +203,9 @@ const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => { enabled = false; } - return { - name, - value, - enabled - }; + const result = { name, value, enabled }; + if (rawAnnotations && rawAnnotations.length) result.annotations = rawAnnotations; + return result; }); }; @@ -197,18 +216,16 @@ const mapRequestParams = (pairList = [], type) => { return _.map(pairList[0], (pair) => { let name = _.keys(pair)[0]; let value = pair[name]; + const rawAnnotations = pair[ANNOTATIONS_KEY]; let enabled = true; if (name && name.length && name.charAt(0) === '~') { name = name.slice(1); enabled = false; } - return { - name, - value, - enabled, - type - }; + const result = { name, value, enabled, type }; + if (rawAnnotations && rawAnnotations.length) result.annotations = rawAnnotations; + return result; }); }; @@ -346,19 +363,67 @@ const sem = grammar.createSemantics().addAttribute('ast', { {} ); }, - dictionary(_1, _2, pairlist, _3) { + dictionary(_1, _2, _3, pairlist, _4) { return pairlist.ast; }, pairlist(_1, pair, _2, rest, _3) { return [pair.ast, ...rest.ast]; }, - pair(_1, key, _2, _3, _4, value, _5) { + pairannotations(entries) { + return entries.ast; + }, + annotationentry(_1, annotation, _2, _3) { + return annotation.ast; + }, + annotation(_at, name, argsIter) { + const annotObj = { name: name.ast }; + const argsArr = argsIter.ast; + if (argsArr.length > 0) { + annotObj.value = argsArr[0]; + } + return annotObj; + }, + annotationname(chars) { + return chars.sourceString; + }, + annotationsinglequotedarg(_open, chars, _close) { + return chars.sourceString; + }, + annotationdoublequotedarg(_open, chars, _close) { + return chars.sourceString; + }, + annotationunquotedarg(chars) { + return chars.sourceString; + }, + annotationargvalue(alt) { + return alt.ast; + }, + annotationmultilinetextblock(_1, content, _2) { + const lines = content.sourceString.split('\n'); + // NOTE: the number 4 is taken from the `multilinetextblock` implementation + let minIndent = 4; + const dedented = lines.map((line) => (line.trim() === '' ? '' : line.substring(minIndent))); + if (dedented.length > 0 && dedented[0] === '') dedented.shift(); + if (dedented.length > 0 && dedented[dedented.length - 1] === '') dedented.pop(); + return dedented.join('\n'); + }, + annotationargscontents(alt) { + return alt.ast; + }, + annotationargs(_open, value, _close) { + return value.ast; + }, + pair(_1, annotations, _keyindent, key, _2, _3, _4, value, _5) { let res = {}; if (Array.isArray(value.ast)) { res[key.ast] = value.ast; - return res; + } else { + res[key.ast] = value.ast ? value.ast.trim() : ''; + } + const annotationList = annotations.ast; + if (annotationList && annotationList.length > 0) { + res[ANNOTATIONS_KEY] = annotationList; } - res[key.ast] = value.ast ? value.ast.trim() : ''; return res; }, esc_quote_char(_1, quote) { @@ -378,9 +443,13 @@ const sem = grammar.createSemantics().addAttribute('ast', { assertpairlist(_1, pair, _2, rest, _3) { return [pair.ast, ...rest.ast]; }, - assertpair(_1, key, _2, _3, _4, value, _5) { + assertpair(_1, annotations, _2, key, _3, _4, _5, value, _6) { let res = {}; res[key.ast] = value.ast ? value.ast.trim() : ''; + const annotationList = annotations.ast; + if (annotationList && annotationList.length > 0) { + res[ANNOTATIONS_KEY] = annotationList; + } return res; }, assertkey(chars) { diff --git a/packages/bruno-lang/v2/src/collectionBruToJson.js b/packages/bruno-lang/v2/src/collectionBruToJson.js index d4bd607d7..00e2c27a7 100644 --- a/packages/bruno-lang/v2/src/collectionBruToJson.js +++ b/packages/bruno-lang/v2/src/collectionBruToJson.js @@ -2,6 +2,10 @@ const ohm = require('ohm-js'); const _ = require('lodash'); const { safeParseJson, outdentString } = require('./utils'); +// this is done to avoid breaking existing pairlist mapping so +// the key is hidden and not added into the json automatically +const ANNOTATIONS_KEY = Symbol('annotations'); + const grammar = ohm.grammar(`Bru { BruFile = (meta | query | headers | auth | auths | vars | script | tests | docs)* auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authOauth2Configs @@ -24,10 +28,27 @@ const grammar = ohm.grammar(`Bru { multilinetextblockdelimiter = "'''" multilinetextblock = multilinetextblockdelimiter (~multilinetextblockdelimiter any)* multilinetextblockdelimiter + // Annotation support (decorators on pairs) + annotationname = annotationchar+ + annotationchar = ~("(" | ")" | " " | "\\t" | "\\r" | "\\n" | ":") any + annotationsinglequotedargchar = ~"'" any + annotationsinglequotedarg = "'" annotationsinglequotedargchar* "'" + annotationdoublequotedargchar = ~"\\"" any + annotationdoublequotedarg = "\\"" annotationdoublequotedargchar* "\\"" + annotationunquotedargchar = ~")" any + annotationunquotedarg = annotationunquotedargchar* + annotationargvalue = annotationsinglequotedarg | annotationdoublequotedarg | annotationunquotedarg + annotationmultilinetextblock = multilinetextblockdelimiter (~multilinetextblockdelimiter any)* multilinetextblockdelimiter + annotationargscontents = annotationmultilinetextblock | annotationargvalue + annotationargs = "(" annotationargscontents ")" + annotation = "@" annotationname annotationargs? + annotationentry = st* annotation ~":" st* nl + pairannotations = annotationentry* + // Dictionary Blocks dictionary = st* "{" pairlist? tagend pairlist = optionalnl* pair (~tagend stnl* pair)* (~tagend space)* - pair = st* (quoted_key | key) st* ":" st* value st* + pair = st* pairannotations st* (quoted_key | key) st* ":" st* value st* disable_char = "~" quote_char = "\\"" esc_char = "\\\\" @@ -87,12 +108,12 @@ const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => { return _.map(pairList[0], (pair) => { let name = _.keys(pair)[0]; let value = pair[name]; + const rawAnnotations = pair[ANNOTATIONS_KEY]; if (!parseEnabled) { - return { - name, - value - }; + const result = { name, value }; + if (rawAnnotations && rawAnnotations.length) result.annotations = rawAnnotations; + return result; } let enabled = true; @@ -101,11 +122,11 @@ const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true) => { enabled = false; } - return { - name, - value, - enabled - }; + const result = { name, value, enabled }; + if (rawAnnotations && rawAnnotations.length) { + result.annotations = rawAnnotations; + } + return result; }); }; @@ -143,9 +164,56 @@ const sem = grammar.createSemantics().addAttribute('ast', { pairlist(_1, pair, _2, rest, _3) { return [pair.ast, ...rest.ast]; }, - pair(_1, key, _2, _3, _4, value, _5) { + pairannotations(entries) { + return entries.ast; + }, + annotationentry(_1, annotation, _2, _3) { + return annotation.ast; + }, + annotation(_at, name, argsIter) { + const annotObj = { name: name.ast }; + const argsArr = argsIter.ast; + if (argsArr.length > 0) { + annotObj.value = argsArr[0]; + } + return annotObj; + }, + annotationname(chars) { + return chars.sourceString; + }, + annotationsinglequotedarg(_open, chars, _close) { + return chars.sourceString; + }, + annotationdoublequotedarg(_open, chars, _close) { + return chars.sourceString; + }, + annotationunquotedarg(chars) { + return chars.sourceString; + }, + annotationargvalue(alt) { + return alt.ast; + }, + annotationmultilinetextblock(_1, content, _2) { + const lines = content.sourceString.split('\n'); + let minIndent = 4; + const dedented = lines.map((line) => (line.trim() === '' ? '' : line.substring(minIndent))); + if (dedented.length > 0 && dedented[0] === '') dedented.shift(); + if (dedented.length > 0 && dedented[dedented.length - 1] === '') dedented.pop(); + return dedented.join('\n'); + }, + annotationargscontents(alt) { + return alt.ast; + }, + annotationargs(_open, value, _close) { + return value.ast; + }, + pair(_1, annotations, _2, key, _3, _4, _5, value, _6) { let res = {}; res[key.ast] = value.ast ? value.ast.trim() : ''; + const annotationList = annotations.ast; + if (annotationList && annotationList.length > 0) { + res[ANNOTATIONS_KEY] = annotationList; + } return res; }, quoted_key(disabled, _1, chars, _2) { diff --git a/packages/bruno-lang/v2/src/envToJson.js b/packages/bruno-lang/v2/src/envToJson.js index a4e97a941..70f036285 100644 --- a/packages/bruno-lang/v2/src/envToJson.js +++ b/packages/bruno-lang/v2/src/envToJson.js @@ -1,6 +1,10 @@ const ohm = require('ohm-js'); const _ = require('lodash'); +// this is done to avoid breaking existing pairlist mapping so +// the key is hidden and not added into the json automatically +const ANNOTATIONS_KEY = Symbol('annotations'); + // Env files use 4-space indentation for multiline content // vars { // API_KEY: ''' @@ -28,10 +32,27 @@ const grammar = ohm.grammar(`Bru { multilinetextblock = multilinetextblockstart multilinetextblockcontent multilinetextblockend multilinetextblockcontent = (~multilinetextblockend any)* + // Annotation support (decorators on pairs) + annotationname = annotationchar+ + annotationchar = ~("(" | ")" | " " | "\\t" | "\\r" | "\\n" | ":") any + annotationsinglequotedargchar = ~"'" any + annotationsinglequotedarg = "'" annotationsinglequotedargchar* "'" + annotationdoublequotedargchar = ~"\\"" any + annotationdoublequotedarg = "\\"" annotationdoublequotedargchar* "\\"" + annotationunquotedargchar = ~")" any + annotationunquotedarg = annotationunquotedargchar* + annotationargvalue = annotationsinglequotedarg | annotationdoublequotedarg | annotationunquotedarg + annotationmultilinetextblock = multilinetextblockdelimiter (~multilinetextblockdelimiter any)* multilinetextblockdelimiter + annotationargscontents = annotationmultilinetextblock | annotationargvalue + annotationargs = "(" annotationargscontents ")" + annotation = "@" annotationname annotationargs? + annotationentry = st* annotation ~":" st* nl + pairannotations = annotationentry* + // Dictionary Blocks dictionary = st* "{" pairlist? tagend pairlist = optionalnl* pair (~tagend stnl* pair)* (~tagend space)* - pair = st* key st* ":" st* value st* + pair = st* pairannotations st* key st* ":" st* value st* key = keychar* value = multilinetextblock | valuechar* @@ -54,17 +75,18 @@ const mapPairListToKeyValPairs = (pairList = []) => { return _.map(pairList[0], (pair) => { let name = _.keys(pair)[0]; let value = pair[name]; + const rawAnnotations = pair[ANNOTATIONS_KEY]; let enabled = true; if (name && name.length && name.charAt(0) === '~') { name = name.slice(1); enabled = false; } - return { - name, - value, - enabled - }; + const result = { name, value, enabled }; + if (rawAnnotations && rawAnnotations.length) { + result.annotations = rawAnnotations; + } + return result; }); }; @@ -128,9 +150,56 @@ const sem = grammar.createSemantics().addAttribute('ast', { pairlist(_1, pair, _2, rest, _3) { return [pair.ast, ...rest.ast]; }, - pair(_1, key, _2, _3, _4, value, _5) { + pairannotations(entries) { + return entries.ast; + }, + annotationentry(_1, annotation, _2, _3) { + return annotation.ast; + }, + annotation(_at, name, argsIter) { + const annotObj = { name: name.ast }; + const argsArr = argsIter.ast; + if (argsArr.length > 0) { + annotObj.value = argsArr[0]; + } + return annotObj; + }, + annotationname(chars) { + return chars.sourceString; + }, + annotationsinglequotedarg(_open, chars, _close) { + return chars.sourceString; + }, + annotationdoublequotedarg(_open, chars, _close) { + return chars.sourceString; + }, + annotationunquotedarg(chars) { + return chars.sourceString; + }, + annotationargvalue(alt) { + return alt.ast; + }, + annotationmultilinetextblock(_1, content, _2) { + const lines = content.sourceString.split('\n'); + let minIndent = 4; + const dedented = lines.map((line) => (line.trim() === '' ? '' : line.substring(minIndent))); + if (dedented.length > 0 && dedented[0] === '') dedented.shift(); + if (dedented.length > 0 && dedented[dedented.length - 1] === '') dedented.pop(); + return dedented.join('\n'); + }, + annotationargscontents(alt) { + return alt.ast; + }, + annotationargs(_open, value, _close) { + return value.ast; + }, + pair(_1, annotations, _2, key, _3, _4, _5, value, _6) { let res = {}; res[key.ast] = value.ast ? value.ast.trim() : ''; + const annotationList = annotations.ast; + if (annotationList && annotationList.length > 0) { + res[ANNOTATIONS_KEY] = annotationList; + } return res; }, key(chars) { diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index 36fcf4585..1e1d5f0da 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -1,6 +1,6 @@ const _ = require('lodash'); -const { indentString, getValueString, getKeyString, getValueUrl } = require('./utils'); +const { indentString, getValueString, getKeyString, getValueUrl, serializeAnnotations } = require('./utils'); const jsonToExampleBru = require('./example/jsonToBru'); const enabled = (items = [], key = 'enabled') => items.filter((item) => item[key]); @@ -128,7 +128,7 @@ const jsonToBru = (json) => { if (enabled(queryParams).length) { bru += `\n${indentString( enabled(queryParams) - .map((item) => `${getKeyString(item.name)}: ${getValueString(item.value)}`) + .map((item) => `${serializeAnnotations(item.annotations)}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -136,7 +136,7 @@ const jsonToBru = (json) => { if (disabled(queryParams).length) { bru += `\n${indentString( disabled(queryParams) - .map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`) + .map((item) => `${serializeAnnotations(item.annotations)}~${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -147,7 +147,7 @@ const jsonToBru = (json) => { if (pathParams.length) { bru += 'params:path {'; - bru += `\n${indentString(pathParams.map((item) => `${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(pathParams.map((item) => `${serializeAnnotations(item.annotations)}${item.name}: ${getValueString(item.value)}`).join('\n'))}`; bru += '\n}\n\n'; } @@ -158,7 +158,7 @@ const jsonToBru = (json) => { if (enabled(headers).length) { bru += `\n${indentString( enabled(headers) - .map((item) => `${getKeyString(item.name)}: ${getValueString(item.value)}`) + .map((item) => `${serializeAnnotations(item.annotations)}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -166,7 +166,7 @@ const jsonToBru = (json) => { if (disabled(headers).length) { bru += `\n${indentString( disabled(headers) - .map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`) + .map((item) => `${serializeAnnotations(item.annotations)}~${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -179,7 +179,7 @@ const jsonToBru = (json) => { if (enabled(metadata).length) { bru += `\n${indentString( enabled(metadata) - .map((item) => `${item.name}: ${getValueString(item.value)}`) + .map((item) => `${serializeAnnotations(item.annotations)}${item.name}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -187,7 +187,7 @@ const jsonToBru = (json) => { if (disabled(metadata).length) { bru += `\n${indentString( disabled(metadata) - .map((item) => `~${item.name}: ${getValueString(item.value)}`) + .map((item) => `${serializeAnnotations(item.annotations)}~${item.name}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -527,14 +527,14 @@ ${indentString(body.sparql)} if (enabled(body.formUrlEncoded).length) { const enabledValues = enabled(body.formUrlEncoded) - .map((item) => `${getKeyString(item.name)}: ${getValueString(item.value)}`) + .map((item) => `${serializeAnnotations(item.annotations)}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n'); bru += `${indentString(enabledValues)}\n`; } if (disabled(body.formUrlEncoded).length) { const disabledValues = disabled(body.formUrlEncoded) - .map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`) + .map((item) => `${serializeAnnotations(item.annotations)}~${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n'); bru += `${indentString(disabledValues)}\n`; } @@ -554,8 +554,9 @@ ${indentString(body.sparql)} const contentType = item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : ''; + const annotPrefix = serializeAnnotations(item.annotations); if (item.type === 'text') { - return `${enabled}${getKeyString(item.name)}: ${getValueString(item.value)}${contentType}`; + return `${annotPrefix}${enabled}${getKeyString(item.name)}: ${getValueString(item.value)}${contentType}`; } if (item.type === 'file') { @@ -563,7 +564,7 @@ ${indentString(body.sparql)} const filestr = filepaths.join('|'); const value = `@file(${filestr})`; - return `${enabled}${getKeyString(item.name)}: ${value}${contentType}`; + return `${annotPrefix}${enabled}${getKeyString(item.name)}: ${value}${contentType}`; } }) .join('\n') @@ -662,19 +663,19 @@ ${indentString(body.sparql)} bru += `vars:pre-request {`; if (varsEnabled.length) { - bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsEnabled.map((item) => `${serializeAnnotations(item.annotations)}${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalEnabled.length) { - bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsLocalEnabled.map((item) => `${serializeAnnotations(item.annotations)}@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsDisabled.length) { - bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsDisabled.map((item) => `${serializeAnnotations(item.annotations)}~${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalDisabled.length) { - bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsLocalDisabled.map((item) => `${serializeAnnotations(item.annotations)}~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } bru += '\n}\n\n'; @@ -688,19 +689,19 @@ ${indentString(body.sparql)} bru += `vars:post-response {`; if (varsEnabled.length) { - bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsEnabled.map((item) => `${serializeAnnotations(item.annotations)}${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalEnabled.length) { - bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsLocalEnabled.map((item) => `${serializeAnnotations(item.annotations)}@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsDisabled.length) { - bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsDisabled.map((item) => `${serializeAnnotations(item.annotations)}~${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalDisabled.length) { - bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsLocalDisabled.map((item) => `${serializeAnnotations(item.annotations)}~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } bru += '\n}\n\n'; @@ -712,7 +713,7 @@ ${indentString(body.sparql)} if (enabled(assertions).length) { bru += `\n${indentString( enabled(assertions) - .map((item) => `${item.name}: ${getValueString(item.value)}`) + .map((item) => `${serializeAnnotations(item.annotations)}${item.name}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -720,7 +721,7 @@ ${indentString(body.sparql)} if (disabled(assertions).length) { bru += `\n${indentString( disabled(assertions) - .map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`) + .map((item) => `${serializeAnnotations(item.annotations)}~${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } diff --git a/packages/bruno-lang/v2/src/jsonToCollectionBru.js b/packages/bruno-lang/v2/src/jsonToCollectionBru.js index 3b9722078..0376f6697 100644 --- a/packages/bruno-lang/v2/src/jsonToCollectionBru.js +++ b/packages/bruno-lang/v2/src/jsonToCollectionBru.js @@ -1,6 +1,6 @@ const _ = require('lodash'); -const { indentString, getValueString, getKeyString } = require('./utils'); +const { indentString, getValueString, getKeyString, serializeAnnotations } = require('./utils'); const enabled = (items = []) => items.filter((item) => item.enabled); const disabled = (items = []) => items.filter((item) => !item.enabled); @@ -30,7 +30,7 @@ const jsonToCollectionBru = (json) => { if (enabled(query).length) { bru += `\n${indentString( enabled(query) - .map((item) => `${getKeyString(item.name)}: ${getValueString(item.value)}`) + .map((item) => `${serializeAnnotations(item.annotations)}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -38,7 +38,7 @@ const jsonToCollectionBru = (json) => { if (disabled(query).length) { bru += `\n${indentString( disabled(query) - .map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`) + .map((item) => `${serializeAnnotations(item.annotations)}~${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -51,7 +51,7 @@ const jsonToCollectionBru = (json) => { if (enabled(headers).length) { bru += `\n${indentString( enabled(headers) - .map((item) => `${getKeyString(item.name)}: ${getValueString(item.value)}`) + .map((item) => `${serializeAnnotations(item.annotations)}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -59,7 +59,7 @@ const jsonToCollectionBru = (json) => { if (disabled(headers).length) { bru += `\n${indentString( disabled(headers) - .map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`) + .map((item) => `${serializeAnnotations(item.annotations)}~${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -379,19 +379,19 @@ ${indentString( bru += `vars:pre-request {`; if (varsEnabled.length) { - bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsEnabled.map((item) => `${serializeAnnotations(item.annotations)}${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalEnabled.length) { - bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsLocalEnabled.map((item) => `${serializeAnnotations(item.annotations)}@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsDisabled.length) { - bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsDisabled.map((item) => `${serializeAnnotations(item.annotations)}~${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalDisabled.length) { - bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsLocalDisabled.map((item) => `${serializeAnnotations(item.annotations)}~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } bru += '\n}\n\n'; @@ -405,19 +405,19 @@ ${indentString( bru += `vars:post-response {`; if (varsEnabled.length) { - bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsEnabled.map((item) => `${serializeAnnotations(item.annotations)}${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalEnabled.length) { - bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsLocalEnabled.map((item) => `${serializeAnnotations(item.annotations)}@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsDisabled.length) { - bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsDisabled.map((item) => `${serializeAnnotations(item.annotations)}~${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalDisabled.length) { - bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; + bru += `\n${indentString(varsLocalDisabled.map((item) => `${serializeAnnotations(item.annotations)}~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } bru += '\n}\n\n'; diff --git a/packages/bruno-lang/v2/src/jsonToEnv.js b/packages/bruno-lang/v2/src/jsonToEnv.js index 09811734b..64a8d2cd5 100644 --- a/packages/bruno-lang/v2/src/jsonToEnv.js +++ b/packages/bruno-lang/v2/src/jsonToEnv.js @@ -1,5 +1,5 @@ const _ = require('lodash'); -const { getValueString, indentString } = require('./utils'); +const { getValueString, indentString, serializeAnnotations } = require('./utils'); const envToJson = (json) => { const variables = _.get(json, 'variables', []); @@ -8,10 +8,10 @@ const envToJson = (json) => { const vars = variables .filter((variable) => !variable.secret) .map((variable) => { - const { name, value, enabled } = variable; + const { name, value, enabled, annotations } = variable; const prefix = enabled ? '' : '~'; - return indentString(`${prefix}${name}: ${getValueString(value)}`); + return indentString(`${serializeAnnotations(annotations)}${prefix}${name}: ${getValueString(value)}`); }); const secretVars = variables diff --git a/packages/bruno-lang/v2/src/utils.js b/packages/bruno-lang/v2/src/utils.js index cff670015..d276f03ae 100644 --- a/packages/bruno-lang/v2/src/utils.js +++ b/packages/bruno-lang/v2/src/utils.js @@ -68,11 +68,28 @@ const getValueUrl = (url) => { return `'''\n${indentString(url, 2)}\n'''`; }; +function serializeAnnotations(annotations) { + if (!annotations?.length) return ''; + return ( + annotations + .map((a) => { + if (a.value === undefined) return `@${a.name}`; + if (a.value.includes('\n')) { + return `@${a.name}('''\n${indentString(a.value)}\n''')`; + } + const quote = a.value.includes('\'') ? '"' : '\''; + return `@${a.name}(${quote}${a.value}${quote})`; + }) + .join('\n') + '\n' + ); +}; + module.exports = { safeParseJson, indentString, outdentString, getValueString, getKeyString, - getValueUrl + getValueUrl, + serializeAnnotations }; diff --git a/packages/bruno-lang/v2/tests/annotations.spec.js b/packages/bruno-lang/v2/tests/annotations.spec.js new file mode 100644 index 000000000..44f5655af --- /dev/null +++ b/packages/bruno-lang/v2/tests/annotations.spec.js @@ -0,0 +1,833 @@ +const parser = require('../src/bruToJson'); +const jsonToBru = require('../src/jsonToBru'); +const envParser = require('../src/envToJson'); +const jsonToEnv = require('../src/jsonToEnv'); +const collectionParser = require('../src/collectionBruToJson'); +const jsonToCollectionBru = require('../src/jsonToCollectionBru'); +const fs = require('fs'); +const path = require('path'); + +describe('pair annotations', () => { + it('above-line annotations in asserts', () => { + const input = ` +assert { + @description('hello') + res.status: eq 200 +} +`; + const output = parser(input); + expect(output.assertions).toEqual([ + { name: 'res.status', value: 'eq 200', enabled: true, annotations: [{ name: 'description', value: 'hello' }] } + ]); + }); + + it('above-line annotation', () => { + const input = ` +headers { + @description('hello') + key: value +} +`; + const output = parser(input); + expect(output.headers).toEqual([ + { name: 'key', value: 'value', enabled: true, annotations: [{ name: 'description', value: 'hello' }] } + ]); + }); + + it('annotation without args', () => { + const input = ` +headers { + @string + key: value +} +`; + const output = parser(input); + expect(output.headers).toEqual([ + { name: 'key', value: 'value', enabled: true, annotations: [{ name: 'string' }] } + ]); + }); + + it('multiple above-line annotations on same pair', () => { + const input = ` +headers { + @string + @description('x') + key: value +} +`; + const output = parser(input); + expect(output.headers).toEqual([ + { + name: 'key', + value: 'value', + enabled: true, + annotations: [{ name: 'string' }, { name: 'description', value: 'x' }] + } + ]); + }); + + it('multiple above-line annotations', () => { + const input = ` +headers { + @string + @description('hello') + key: value +} +`; + const output = parser(input); + expect(output.headers).toEqual([ + { + name: 'key', + value: 'value', + enabled: true, + annotations: [{ name: 'string' }, { name: 'description', value: 'hello' }] + } + ]); + }); + + it('no annotation — output unchanged (backward compat)', () => { + const input = ` +headers { + key: value +} +`; + const output = parser(input); + expect(output.headers).toEqual([{ name: 'key', value: 'value', enabled: true }]); + expect(output.headers[0]).not.toHaveProperty('annotations'); + }); + + it('disabled pair with annotation', () => { + const input = ` +headers { + @string + ~key: value +} +`; + const output = parser(input); + expect(output.headers).toEqual([ + { name: 'key', value: 'value', enabled: false, annotations: [{ name: 'string' }] } + ]); + }); + + it('double-quoted annotation arg', () => { + const input = ` +headers { + @description("hello") + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: 'hello' }]); + }); + + it('single quote inside double-quoted annotation arg (e.g. O\'Reilly)', () => { + const input = ` +headers { + @description("O'Reilly") + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: 'O\'Reilly' }]); + }); + + it('double quote inside single-quoted annotation arg (e.g. say "hello")', () => { + const input = ` +headers { + @description('say "hello"') + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: 'say "hello"' }]); + }); + + it('smoke test escaping special characters', () => { + const input = fs.readFileSync(path.join(__dirname, './fixtures/annotations.bru')); + const output = parser(input); + expect(output.vars.req[0].annotations).toEqual([{ name: 'description', value: 'found in C:\\Users\\File\\Path' }]); + expect(output.vars.req[1].annotations).toEqual([{ name: 'description', value: 'height of 2\' ' }]); + }); + + it('unquoted annotation arg', () => { + const input = ` +headers { + @version(2) + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'version', value: '2' }]); + }); + + it('float (decimal) unquoted annotation arg', () => { + const input = ` +headers { + @version(3.14) + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'version', value: '3.14' }]); + }); + + it('empty string arg', () => { + const input = ` +headers { + @description('') + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: '' }]); + }); + + it('whitespace-only string arg preserves spaces', () => { + const input = ` +headers { + @description(' ') + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: ' ' }]); + }); + + it('leading and trailing whitespace in string arg is preserved', () => { + const input = ` +headers { + @description(' hello ') + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: ' hello ' }]); + }); + + it('unicode characters in annotation arg', () => { + const input = ` +headers { + @description('日本語') + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: '日本語' }]); + }); + + it('URL with query string in annotation arg', () => { + const input = ` +headers { + @description('https://example.com/path?q=1&r=2#anchor') + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: 'https://example.com/path?q=1&r=2#anchor' }]); + }); + + it('colon inside annotation arg value', () => { + const input = ` +headers { + @description('Content-Type: application/json') + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: 'Content-Type: application/json' }]); + }); + + it('email address (@ symbol) inside annotation arg value', () => { + const input = ` +headers { + @description('user@example.com') + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: 'user@example.com' }]); + }); + + it('template variable syntax inside annotation arg value', () => { + const input = ` +headers { + @description('{{baseUrl}}/endpoint') + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: '{{baseUrl}}/endpoint' }]); + }); + + it('tab character inside annotation arg value', () => { + const input = `headers {\n @description('col1\tcol2')\n key: value\n}\n`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: 'col1\tcol2' }]); + }); + + it('multiline string values', () => { + const input = `headers { + @description(''' + make it rain + make it rain2 + ''') + key: value +}`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: 'make it rain\nmake it rain2' }]); + }); + + it('serializeAnnotations — multiline value uses triple-quote delimiters and roundtrips correctly', () => { + const json = { + meta: { name: 'test', type: 'http', seq: 1 }, + http: { method: 'get', url: 'https://example.com' }, + headers: [{ name: 'x-key', value: 'val', enabled: true, annotations: [{ name: 'description', value: 'line one\nline two' }] }] + }; + const bru = jsonToBru(json); + expect(bru).toContain('@description(\'\'\'\n line one\n line two\n \'\'\')\n x-key: val'); const parsed = parser(bru); + expect(parsed.headers[0].annotations).toEqual([{ name: 'description', value: 'line one\nline two' }]); + }); + + it('serializeAnnotations — empty string value roundtrips correctly', () => { + const json = { + meta: { name: 'test', type: 'http', seq: 1 }, + http: { method: 'get', url: 'https://example.com' }, + headers: [{ name: 'x-key', value: 'val', enabled: true, annotations: [{ name: 'description', value: '' }] }] + }; + const bru = jsonToBru(json); + const parsed = parser(bru); + expect(parsed.headers[0].annotations).toEqual([{ name: 'description', value: '' }]); + }); + + it('serializeAnnotations — URL with special chars uses single-quote delimiters', () => { + const json = { + meta: { name: 'test', type: 'http', seq: 1 }, + http: { method: 'get', url: 'https://example.com' }, + headers: [{ name: 'x-key', value: 'val', enabled: true, annotations: [{ name: 'description', value: 'https://example.com?q=1&r=2' }] }] + }; + const bru = jsonToBru(json); + expect(bru).toContain('@description(\'https://example.com?q=1&r=2\')\n x-key: val'); + }); + + it('serializeAnnotations — template variable in value roundtrips correctly', () => { + const json = { + meta: { name: 'test', type: 'http', seq: 1 }, + http: { method: 'get', url: 'https://example.com' }, + headers: [{ name: 'x-key', value: 'val', enabled: true, annotations: [{ name: 'description', value: '{{baseUrl}}/path' }] }] + }; + const bru = jsonToBru(json); + const parsed = parser(bru); + expect(parsed.headers[0].annotations).toEqual([{ name: 'description', value: '{{baseUrl}}/path' }]); + }); + + it('annotation on params:query block', () => { + const input = ` +params:query { + @string + q: search +} +`; + const output = parser(input); + expect(output.params).toEqual([ + { name: 'q', value: 'search', enabled: true, type: 'query', annotations: [{ name: 'string' }] } + ]); + }); + + it('annotation on vars:pre-request block', () => { + const input = ` +vars:pre-request { + @description('base url') + myVar: http://localhost +} +`; + const output = parser(input); + expect(output.vars.req).toEqual([ + { + name: 'myVar', + value: 'http://localhost', + enabled: true, + local: false, + annotations: [{ name: 'description', value: 'base url' }] + } + ]); + }); + + it('roundtrip: bru → json → bru → json equal', () => { + const input = `get { + url: https://example.com +} + +headers { + @description('Content type') + content-type: application/json + @string + ~accept: */* +} +`; + const json1 = parser(input); + const bru = jsonToBru(json1); + const json2 = parser(bru); + expect(json2.headers).toEqual(json1.headers); + }); + + it('serializeAnnotations — annotation without value', () => { + const json = { + meta: { name: 'test', type: 'http', seq: 1 }, + http: { method: 'get', url: 'https://example.com' }, + headers: [{ name: 'x-key', value: 'val', enabled: true, annotations: [{ name: 'string' }] }] + }; + const bru = jsonToBru(json); + expect(bru).toContain('@string\n x-key: val'); + }); + + it('serializeAnnotations — annotation with value', () => { + const json = { + meta: { name: 'test', type: 'http', seq: 1 }, + http: { method: 'get', url: 'https://example.com' }, + headers: [ + { name: 'x-key', value: 'val', enabled: true, annotations: [{ name: 'description', value: 'my header' }] } + ] + }; + const bru = jsonToBru(json); + expect(bru).toContain('@description(\'my header\')\n x-key: val'); + }); + + it('serializeAnnotations — disabled pair with annotation', () => { + const json = { + meta: { name: 'test', type: 'http', seq: 1 }, + http: { method: 'get', url: 'https://example.com' }, + headers: [{ name: 'x-key', value: 'val', enabled: false, annotations: [{ name: 'string' }] }] + }; + const bru = jsonToBru(json); + expect(bru).toContain('@string\n ~x-key: val'); + }); + + it('serializeAnnotations — value with single quote uses double-quote delimiters (e.g. O\'Reilly)', () => { + const json = { + meta: { name: 'test', type: 'http', seq: 1 }, + http: { method: 'get', url: 'https://example.com' }, + headers: [{ name: 'x-key', value: 'val', enabled: true, annotations: [{ name: 'description', value: 'O\'Reilly' }] }] + }; + const bru = jsonToBru(json); + expect(bru).toContain('@description("O\'Reilly")\n x-key: val'); + }); + + it('serializeAnnotations — value with double quote uses single-quote delimiters (e.g. say "hello")', () => { + const json = { + meta: { name: 'test', type: 'http', seq: 1 }, + http: { method: 'get', url: 'https://example.com' }, + headers: [{ name: 'x-key', value: 'val', enabled: true, annotations: [{ name: 'description', value: 'say "hello"' }] }] + }; + const bru = jsonToBru(json); + expect(bru).toContain('@description(\'say "hello"\')\n x-key: val'); + }); + + it('parseAndSerialise - bru sourced roundtrip check - headers', () => { + const input = `headers { + @description('hello') + key: value +} +`; + const parsed = parser(input); + const output = jsonToBru(parsed); + + expect(input).toEqual(output); + }); + + it('parseAndSerialise - json sourced roundtrip check - headers', () => { + const input = { + headers: [{ name: 'x-key', value: 'val', enabled: true, annotations: [{ name: 'description', value: 'say "hello"' }] }] + }; + const stringified = jsonToBru(input); + const output = parser(stringified); + + expect(input).toEqual(output); + }); + + it('parseAndSerialise - bru sourced roundtrip check - asserts', () => { + const input = `assert { + @description('make it rain') + res.status: eq 200 +} +`; + + const parsed = parser(input); + const output = jsonToBru(parsed); + + expect(input).toEqual(output); + }); + + it('parseAndSerialise - json sourced roundtrip check - asserts', () => { + const input = { + assertions: [ + { + annotations: [{ name: 'description', value: 'hello' }], + name: 'res.status', value: 'eq 200', enabled: true } + ] + }; + + const parsed = jsonToBru(input); + const output = parser(parsed); + + expect(input).toEqual(output); + }); + + it('paren inside single-quoted annotation arg — Token (JWT)', () => { + const input = `headers { + @description('Token (JWT)') + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: 'Token (JWT)' }]); + }); + + it('paren inside double-quoted annotation arg — Result (OK)', () => { + const input = `headers { + @description("Result (OK)") + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: 'Result (OK)' }]); + }); + + it('multiple parens inside single-quoted annotation arg', () => { + const input = `headers { + @description('func(a, b) returns (c)') + key: value +} +`; + const output = parser(input); + expect(output.headers[0].annotations).toEqual([{ name: 'description', value: 'func(a, b) returns (c)' }]); + }); + + it('roundtrip — value containing parens survives json→bru→json — Token (JWT)', () => { + const json = { + meta: { name: 'test', type: 'http', seq: 1 }, + http: { method: 'get', url: 'https://example.com' }, + headers: [ + { name: 'Authorization', value: 'Bearer token', enabled: true, annotations: [{ name: 'description', value: 'Token (JWT)' }] } + ] + }; + const bru = jsonToBru(json); + const parsed = parser(bru); + expect(parsed.headers[0].annotations).toEqual([{ name: 'description', value: 'Token (JWT)' }]); + }); + + it('inline annotation on a header is rejected', () => { + const input = ` +headers { + @string key: value +} +`; + expect(() => parser(input)).toThrow(); + }); +}); + +describe('env pair annotations', () => { + it('above-line annotation with string arg on a var', () => { + const input = `vars { + @description('my api key') + API_KEY: abc123 +} +`; + const output = envParser(input); + expect(output.variables).toEqual([ + { name: 'API_KEY', value: 'abc123', enabled: true, secret: false, annotations: [{ name: 'description', value: 'my api key' }] } + ]); + }); + + it('above-line annotation on a var', () => { + const input = `vars { + @deprecated + OLD_KEY: old_value +} +`; + const output = envParser(input); + expect(output.variables).toEqual([ + { name: 'OLD_KEY', value: 'old_value', enabled: true, secret: false, annotations: [{ name: 'deprecated' }] } + ]); + }); + + it('annotation without args on a var', () => { + const input = `vars { + @string + API_KEY: abc +} +`; + const output = envParser(input); + expect(output.variables[0].annotations).toEqual([{ name: 'string' }]); + }); + + it('multiple annotations on a var', () => { + const input = `vars { + @string + @description('base url') + BASE_URL: http://localhost +} +`; + const output = envParser(input); + expect(output.variables[0].annotations).toEqual([{ name: 'string' }, { name: 'description', value: 'base url' }]); + }); + + it('disabled var with annotation', () => { + const input = `vars { + @deprecated + ~OLD_KEY: old_value +} +`; + const output = envParser(input); + expect(output.variables).toEqual([ + { name: 'OLD_KEY', value: 'old_value', enabled: false, secret: false, annotations: [{ name: 'deprecated' }] } + ]); + }); + + it('no annotation — output unchanged (backward compat)', () => { + const input = `vars { + API_KEY: abc123 +} +`; + const output = envParser(input); + expect(output.variables[0]).not.toHaveProperty('annotations'); + expect(output.variables[0]).toEqual({ name: 'API_KEY', value: 'abc123', enabled: true, secret: false }); + }); + + it('secret vars are unaffected by annotation support', () => { + const input = `vars:secret [ + SECRET_KEY +] +`; + const output = envParser(input); + expect(output.variables).toEqual([{ name: 'SECRET_KEY', value: '', enabled: true, secret: true }]); + }); + + it('serializeAnnotations in jsonToEnv — annotation without value', () => { + const json = { + variables: [{ name: 'API_KEY', value: 'abc', enabled: true, secret: false, annotations: [{ name: 'deprecated' }] }] + }; + const bru = jsonToEnv(json); + expect(bru).toContain('@deprecated\n API_KEY: abc'); + }); + + it('serializeAnnotations in jsonToEnv — annotation with value', () => { + const json = { + variables: [{ name: 'BASE_URL', value: 'http://localhost', enabled: true, secret: false, annotations: [{ name: 'description', value: 'base url' }] }] + }; + const bru = jsonToEnv(json); + expect(bru).toContain('@description(\'base url\')\n BASE_URL: http://localhost'); + }); + + it('serializeAnnotations in jsonToEnv — disabled var with annotation', () => { + const json = { + variables: [{ name: 'OLD_KEY', value: 'old', enabled: false, secret: false, annotations: [{ name: 'deprecated' }] }] + }; + const bru = jsonToEnv(json); + expect(bru).toContain('@deprecated\n ~OLD_KEY: old'); + }); + + it('parseAndSerialise - bru sourced roundtrip check - env vars', () => { + const input = `vars { + @description('api key') + API_KEY: abc123 +} +`; + const parsed = envParser(input); + const output = jsonToEnv(parsed); + expect(output).toEqual(input); + }); + + it('parseAndSerialise - json sourced roundtrip check - env vars', () => { + const input = { + variables: [{ name: 'API_KEY', value: 'abc123', enabled: true, secret: false, annotations: [{ name: 'description', value: 'api key' }] }] + }; + const bru = jsonToEnv(input); + const output = envParser(bru); + expect(output).toEqual(input); + }); + + it('inline annotation on an env var is rejected', () => { + const input = `vars { + @deprecated API_KEY: abc +} +`; + expect(() => envParser(input)).toThrow(); + }); +}); + +describe('collection pair annotations', () => { + it('above-line annotation on a header (collection)', () => { + const input = `headers { + @description('content type') + content-type: application/json +} +`; + const output = collectionParser(input); + expect(output.headers).toEqual([ + { name: 'content-type', value: 'application/json', enabled: true, annotations: [{ name: 'description', value: 'content type' }] } + ]); + }); + + it('above-line annotation on a header', () => { + const input = `headers { + @deprecated + old-header: old-value +} +`; + const output = collectionParser(input); + expect(output.headers).toEqual([ + { name: 'old-header', value: 'old-value', enabled: true, annotations: [{ name: 'deprecated' }] } + ]); + }); + + it('annotation on a query param', () => { + const input = `query { + @string + q: search +} +`; + const output = collectionParser(input); + expect(output.query).toEqual([ + { name: 'q', value: 'search', enabled: true, annotations: [{ name: 'string' }] } + ]); + }); + + it('disabled header with annotation', () => { + const input = `headers { + @deprecated + ~x-old: value +} +`; + const output = collectionParser(input); + expect(output.headers).toEqual([ + { name: 'x-old', value: 'value', enabled: false, annotations: [{ name: 'deprecated' }] } + ]); + }); + + it('annotation on vars:pre-request', () => { + const input = `vars:pre-request { + @description('base url') + BASE_URL: http://localhost +} +`; + const output = collectionParser(input); + expect(output.vars.req).toEqual([ + { name: 'BASE_URL', value: 'http://localhost', enabled: true, local: false, annotations: [{ name: 'description', value: 'base url' }] } + ]); + }); + + it('annotation on vars:post-response', () => { + const input = `vars:post-response { + @string + token: abc +} +`; + const output = collectionParser(input); + expect(output.vars.res).toEqual([ + { name: 'token', value: 'abc', enabled: true, local: false, annotations: [{ name: 'string' }] } + ]); + }); + + it('local var (@-prefixed) is not misidentified as annotation', () => { + const input = `vars:pre-request { + @localVar: http://localhost +} +`; + const output = collectionParser(input); + expect(output.vars.req).toEqual([ + { name: 'localVar', value: 'http://localhost', enabled: true, local: true } + ]); + expect(output.vars.req[0]).not.toHaveProperty('annotations'); + }); + + it('no annotation — output unchanged (backward compat)', () => { + const input = `headers { + content-type: application/json +} +`; + const output = collectionParser(input); + expect(output.headers[0]).not.toHaveProperty('annotations'); + expect(output.headers[0]).toEqual({ name: 'content-type', value: 'application/json', enabled: true }); + }); + + it('serializeAnnotations in jsonToCollectionBru — header without value', () => { + const json = { + headers: [{ name: 'x-key', value: 'val', enabled: true, annotations: [{ name: 'string' }] }] + }; + const bru = jsonToCollectionBru(json); + expect(bru).toContain('@string\n x-key: val'); + }); + + it('serializeAnnotations in jsonToCollectionBru — header with annotation value', () => { + const json = { + headers: [{ name: 'content-type', value: 'application/json', enabled: true, annotations: [{ name: 'description', value: 'content type' }] }] + }; + const bru = jsonToCollectionBru(json); + expect(bru).toContain('@description(\'content type\')\n content-type: application/json'); + }); + + it('serializeAnnotations in jsonToCollectionBru — disabled header with annotation', () => { + const json = { + headers: [{ name: 'x-old', value: 'val', enabled: false, annotations: [{ name: 'deprecated' }] }] + }; + const bru = jsonToCollectionBru(json); + expect(bru).toContain('@deprecated\n ~x-old: val'); + }); + + it('serializeAnnotations in jsonToCollectionBru — query param with annotation', () => { + const json = { + query: [{ name: 'q', value: 'search', enabled: true, annotations: [{ name: 'string' }] }] + }; + const bru = jsonToCollectionBru(json); + expect(bru).toContain('@string\n q: search'); + }); + + it('serializeAnnotations in jsonToCollectionBru — vars:pre-request with annotation', () => { + const json = { + vars: { + req: [{ name: 'BASE_URL', value: 'http://localhost', enabled: true, local: false, annotations: [{ name: 'description', value: 'base url' }] }] + } + }; + const bru = jsonToCollectionBru(json); + expect(bru).toContain('@description(\'base url\')\n BASE_URL: http://localhost'); + }); + + it('parseAndSerialise - bru sourced roundtrip check - collection headers', () => { + const input = `headers { + @description('content type') + content-type: application/json +} +`; + const parsed = collectionParser(input); + const output = jsonToCollectionBru(parsed); + expect(output).toEqual(input); + }); + + it('parseAndSerialise - json sourced roundtrip check - collection headers', () => { + const input = { + headers: [{ name: 'content-type', value: 'application/json', enabled: true, annotations: [{ name: 'description', value: 'content type' }] }] + }; + const bru = jsonToCollectionBru(input); + const output = collectionParser(bru); + expect(output).toEqual(input); + }); + + it('parseAndSerialise - bru sourced roundtrip check - collection vars:pre-request', () => { + const input = `vars:pre-request { + @description('base url') + BASE_URL: http://localhost +} +`; + const parsed = collectionParser(input); + const output = jsonToCollectionBru(parsed); + expect(output).toEqual(input); + }); + + it('inline annotation on a collection header is rejected', () => { + const input = `headers { + @string x-key: val +} +`; + expect(() => collectionParser(input)).toThrow(); + }); +}); diff --git a/packages/bruno-lang/v2/tests/fixtures/annotations.bru b/packages/bruno-lang/v2/tests/fixtures/annotations.bru new file mode 100644 index 000000000..2acbeeee2 --- /dev/null +++ b/packages/bruno-lang/v2/tests/fixtures/annotations.bru @@ -0,0 +1,6 @@ +vars:pre-request { + @description("found in C:\Users\File\Path") + key:value + @description("height of 2' ") + key2:value +}